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,596 @@
|
|
|
1
|
+
"""Deterministic assertions for batch evaluation.
|
|
2
|
+
|
|
3
|
+
Ten assertion types implement mechanical pass/fail checks over LLM outputs.
|
|
4
|
+
The whole point is that "deterministic" means: given the same output, the
|
|
5
|
+
result is always the same - no LLM judge in the loop, no fuzziness. Useful
|
|
6
|
+
for prompt regression tests in CI/CD.
|
|
7
|
+
|
|
8
|
+
The dispatcher is data-driven: a `dict[str, callable]` maps each type to
|
|
9
|
+
its check function. Adding a new type means adding one entry plus one
|
|
10
|
+
function.
|
|
11
|
+
|
|
12
|
+
`jsonschema` is an OPTIONAL dependency. Nine of ten assertion types work
|
|
13
|
+
without it; only `json_schema` needs it. The import happens inside the
|
|
14
|
+
check function so:
|
|
15
|
+
a) tests can simulate a missing install with monkeypatch
|
|
16
|
+
b) users who don't need schema validation don't pay the import cost
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from enum import StrEnum
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from cli_modelarium.exceptions import AssertionConfigError
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AssertionType(StrEnum):
|
|
31
|
+
"""Supported assertion types. `str` subclass so values JSON-serialize natively."""
|
|
32
|
+
|
|
33
|
+
CONTAINS = "contains"
|
|
34
|
+
NOT_CONTAINS = "not_contains"
|
|
35
|
+
REGEX = "regex"
|
|
36
|
+
EQUALS = "equals"
|
|
37
|
+
JSON_VALID = "json_valid"
|
|
38
|
+
JSON_SCHEMA = "json_schema"
|
|
39
|
+
MIN_LENGTH_CHARS = "min_length_chars"
|
|
40
|
+
MAX_LENGTH_CHARS = "max_length_chars"
|
|
41
|
+
LATENCY_UNDER = "latency_under"
|
|
42
|
+
COST_UNDER = "cost_under"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_ASSERTION_VALUES: set[str] = {t.value for t in AssertionType}
|
|
46
|
+
|
|
47
|
+
# json_valid is the only type that doesn't need a `value` field.
|
|
48
|
+
_NO_VALUE_TYPES: set[str] = {AssertionType.JSON_VALID.value}
|
|
49
|
+
|
|
50
|
+
# Display markers. Prefer Unicode by default; ASCII fallbacks available
|
|
51
|
+
# for environments with terminal encoding issues or downstream tools that
|
|
52
|
+
# choke on Unicode.
|
|
53
|
+
PASS_MARK = "✓" # ✓
|
|
54
|
+
FAIL_MARK = "✗" # ✗
|
|
55
|
+
ERROR_MARK = "⚠" # ⚠
|
|
56
|
+
PASS_MARK_ASCII = "PASS"
|
|
57
|
+
FAIL_MARK_ASCII = "FAIL"
|
|
58
|
+
ERROR_MARK_ASCII = "ERR"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class AssertionConfig:
|
|
63
|
+
"""A validated assertion configuration ready to run."""
|
|
64
|
+
|
|
65
|
+
type: str
|
|
66
|
+
value: Any = None
|
|
67
|
+
case_sensitive: bool = True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class AssertionResult:
|
|
72
|
+
"""The outcome of running one assertion against one output.
|
|
73
|
+
|
|
74
|
+
Semantics:
|
|
75
|
+
passed=True, error=None - assertion ran and succeeded
|
|
76
|
+
passed=False, error=None - assertion ran and the output failed it
|
|
77
|
+
passed=False, error="..." - assertion COULDN'T run (config error,
|
|
78
|
+
missing dependency, regex compile fail)
|
|
79
|
+
|
|
80
|
+
The exit-code logic in cli.py treats `error`-set results as neither pass
|
|
81
|
+
nor fail - they're surfaced but don't fail the build.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
type: str
|
|
85
|
+
passed: bool
|
|
86
|
+
expected: Any
|
|
87
|
+
actual: Any
|
|
88
|
+
message: str
|
|
89
|
+
error: str | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ===== config parsing =====
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def parse_assertion_config(raw: Any) -> AssertionConfig:
|
|
96
|
+
"""Validate a raw dict from a JSON batch file's `assertions` array.
|
|
97
|
+
|
|
98
|
+
Raises AssertionConfigError with the offending config in the message so
|
|
99
|
+
callers can surface it to the user.
|
|
100
|
+
"""
|
|
101
|
+
if not isinstance(raw, dict):
|
|
102
|
+
raise AssertionConfigError(
|
|
103
|
+
f"Assertion config must be an object, got {type(raw).__name__}: {raw!r}"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
raw_type = raw.get("type")
|
|
107
|
+
if raw_type is None:
|
|
108
|
+
raise AssertionConfigError(f"Assertion config missing required 'type' field: {raw!r}")
|
|
109
|
+
if not isinstance(raw_type, str):
|
|
110
|
+
raise AssertionConfigError(
|
|
111
|
+
f"Assertion 'type' must be a string, got {type(raw_type).__name__}: {raw!r}"
|
|
112
|
+
)
|
|
113
|
+
if raw_type not in _ASSERTION_VALUES:
|
|
114
|
+
supported = ", ".join(sorted(_ASSERTION_VALUES))
|
|
115
|
+
raise AssertionConfigError(f"Unknown assertion type {raw_type!r}. Supported: {supported}")
|
|
116
|
+
|
|
117
|
+
if raw_type not in _NO_VALUE_TYPES and "value" not in raw:
|
|
118
|
+
raise AssertionConfigError(f"Assertion type {raw_type!r} requires a 'value' field: {raw!r}")
|
|
119
|
+
|
|
120
|
+
case_sensitive_raw = raw.get("case_sensitive", True)
|
|
121
|
+
case_sensitive = bool(case_sensitive_raw)
|
|
122
|
+
|
|
123
|
+
return AssertionConfig(
|
|
124
|
+
type=raw_type,
|
|
125
|
+
value=raw.get("value"),
|
|
126
|
+
case_sensitive=case_sensitive,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ===== runner =====
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def run_assertions(
|
|
134
|
+
output: str,
|
|
135
|
+
latency_ms: float | None,
|
|
136
|
+
cost_usd: float,
|
|
137
|
+
assertions: list[dict[str, Any]],
|
|
138
|
+
) -> list[AssertionResult]:
|
|
139
|
+
"""Run each assertion in order. Never raises.
|
|
140
|
+
|
|
141
|
+
Config errors become AssertionResult with `error` set, preserving the
|
|
142
|
+
type-name for the row's display while signalling that this one should
|
|
143
|
+
NOT count toward pass/fail tallies.
|
|
144
|
+
"""
|
|
145
|
+
results: list[AssertionResult] = []
|
|
146
|
+
for raw in assertions:
|
|
147
|
+
try:
|
|
148
|
+
config = parse_assertion_config(raw)
|
|
149
|
+
except AssertionConfigError as e:
|
|
150
|
+
raw_type = raw.get("type", "<unknown>") if isinstance(raw, dict) else "<unknown>"
|
|
151
|
+
results.append(
|
|
152
|
+
AssertionResult(
|
|
153
|
+
type=str(raw_type),
|
|
154
|
+
passed=False,
|
|
155
|
+
expected=None,
|
|
156
|
+
actual=None,
|
|
157
|
+
message="invalid assertion config",
|
|
158
|
+
error=str(e),
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
result = _dispatch(config, output, latency_ms, cost_usd)
|
|
165
|
+
except Exception as e: # noqa: BLE001 - defensive; impls shouldn't raise
|
|
166
|
+
result = AssertionResult(
|
|
167
|
+
type=config.type,
|
|
168
|
+
passed=False,
|
|
169
|
+
expected=config.value,
|
|
170
|
+
actual=None,
|
|
171
|
+
message="assertion check raised unexpectedly",
|
|
172
|
+
error=f"{type(e).__name__}: {e}",
|
|
173
|
+
)
|
|
174
|
+
results.append(result)
|
|
175
|
+
return results
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _dispatch(
|
|
179
|
+
config: AssertionConfig,
|
|
180
|
+
output: str,
|
|
181
|
+
latency_ms: float | None,
|
|
182
|
+
cost_usd: float,
|
|
183
|
+
) -> AssertionResult:
|
|
184
|
+
"""Route a parsed config to its check function."""
|
|
185
|
+
checker = _CHECKERS.get(config.type)
|
|
186
|
+
if checker is None:
|
|
187
|
+
return AssertionResult(
|
|
188
|
+
type=config.type,
|
|
189
|
+
passed=False,
|
|
190
|
+
expected=config.value,
|
|
191
|
+
actual=None,
|
|
192
|
+
message="unknown assertion type",
|
|
193
|
+
error=f"no checker registered for type: {config.type}",
|
|
194
|
+
)
|
|
195
|
+
return checker(config, output, latency_ms, cost_usd)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ===== aggregation helpers =====
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def all_passed(results: list[AssertionResult]) -> bool:
|
|
202
|
+
"""True if every result either passed or errored (error rows don't count as fail).
|
|
203
|
+
|
|
204
|
+
Empty list passes vacuously.
|
|
205
|
+
"""
|
|
206
|
+
return all(r.passed or r.error is not None for r in results)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def count_passed(results: list[AssertionResult]) -> tuple[int, int]:
|
|
210
|
+
"""Return (passed_count, total_definitive_count).
|
|
211
|
+
|
|
212
|
+
Definitive = passed=True OR (passed=False AND error is None). Results
|
|
213
|
+
with `error` set are EXCLUDED from both numerator and denominator -
|
|
214
|
+
they couldn't run, so they aren't a verdict either way.
|
|
215
|
+
"""
|
|
216
|
+
passed = sum(1 for r in results if r.passed and r.error is None)
|
|
217
|
+
definitive = sum(1 for r in results if r.error is None)
|
|
218
|
+
return passed, definitive
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def count_failed(results: list[AssertionResult]) -> int:
|
|
222
|
+
"""Number of definitive failures (excludes error rows)."""
|
|
223
|
+
return sum(1 for r in results if not r.passed and r.error is None)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def failed_types(results: list[AssertionResult]) -> list[str]:
|
|
227
|
+
"""Distinct types of definitively-failed assertions, preserving first-occurrence order."""
|
|
228
|
+
seen: list[str] = []
|
|
229
|
+
seen_set: set[str] = set()
|
|
230
|
+
for r in results:
|
|
231
|
+
if not r.passed and r.error is None and r.type not in seen_set:
|
|
232
|
+
seen.append(r.type)
|
|
233
|
+
seen_set.add(r.type)
|
|
234
|
+
return seen
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def format_assertion_message(result: AssertionResult) -> str:
|
|
238
|
+
"""Render an AssertionResult as a one-line human-readable string."""
|
|
239
|
+
if result.error is not None:
|
|
240
|
+
return f"{ERROR_MARK} {result.type}: {result.error}"
|
|
241
|
+
mark = PASS_MARK if result.passed else FAIL_MARK
|
|
242
|
+
return f"{mark} {result.type}: {result.message}"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ===== assertion implementations =====
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _strip_code_fence(text: str) -> str:
|
|
249
|
+
"""Strip an outer ```json (or bare ```) fence if present. Otherwise unchanged."""
|
|
250
|
+
text = text.strip()
|
|
251
|
+
if not text.startswith("```"):
|
|
252
|
+
return text
|
|
253
|
+
lines = text.split("\n")
|
|
254
|
+
# Drop the opening fence line (handles both ``` and ```json).
|
|
255
|
+
lines = lines[1:]
|
|
256
|
+
while lines and lines[-1].strip() == "```":
|
|
257
|
+
lines.pop()
|
|
258
|
+
return "\n".join(lines).strip()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _check_contains(
|
|
262
|
+
config: AssertionConfig, output: str, latency_ms: float | None, cost_usd: float
|
|
263
|
+
) -> AssertionResult:
|
|
264
|
+
value = str(config.value)
|
|
265
|
+
if config.case_sensitive:
|
|
266
|
+
found = value in output
|
|
267
|
+
else:
|
|
268
|
+
found = value.lower() in output.lower()
|
|
269
|
+
return AssertionResult(
|
|
270
|
+
type=config.type,
|
|
271
|
+
passed=found,
|
|
272
|
+
expected=value,
|
|
273
|
+
actual="found" if found else "not found",
|
|
274
|
+
message=(f"contains {value!r}" if found else f"output does not contain {value!r}"),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _check_not_contains(
|
|
279
|
+
config: AssertionConfig, output: str, latency_ms: float | None, cost_usd: float
|
|
280
|
+
) -> AssertionResult:
|
|
281
|
+
value = str(config.value)
|
|
282
|
+
if config.case_sensitive:
|
|
283
|
+
found = value in output
|
|
284
|
+
else:
|
|
285
|
+
found = value.lower() in output.lower()
|
|
286
|
+
return AssertionResult(
|
|
287
|
+
type=config.type,
|
|
288
|
+
passed=not found,
|
|
289
|
+
expected=value,
|
|
290
|
+
actual="found" if found else "not found",
|
|
291
|
+
message=(
|
|
292
|
+
f"output does not contain {value!r}"
|
|
293
|
+
if not found
|
|
294
|
+
else f"forbidden substring {value!r} appears in output"
|
|
295
|
+
),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _check_regex(
|
|
300
|
+
config: AssertionConfig, output: str, latency_ms: float | None, cost_usd: float
|
|
301
|
+
) -> AssertionResult:
|
|
302
|
+
pattern = str(config.value)
|
|
303
|
+
flags = re.DOTALL | (0 if config.case_sensitive else re.IGNORECASE)
|
|
304
|
+
try:
|
|
305
|
+
compiled = re.compile(pattern, flags)
|
|
306
|
+
except re.error as e:
|
|
307
|
+
return AssertionResult(
|
|
308
|
+
type=config.type,
|
|
309
|
+
passed=False,
|
|
310
|
+
expected=pattern,
|
|
311
|
+
actual=None,
|
|
312
|
+
message="invalid regex pattern",
|
|
313
|
+
error=f"re.error: {e}",
|
|
314
|
+
)
|
|
315
|
+
match = compiled.search(output)
|
|
316
|
+
return AssertionResult(
|
|
317
|
+
type=config.type,
|
|
318
|
+
passed=match is not None,
|
|
319
|
+
expected=pattern,
|
|
320
|
+
actual=(match.group(0)[:80] if match else "no match"),
|
|
321
|
+
message=(f"regex {pattern!r} matched" if match else f"regex {pattern!r} did not match"),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _check_equals(
|
|
326
|
+
config: AssertionConfig, output: str, latency_ms: float | None, cost_usd: float
|
|
327
|
+
) -> AssertionResult:
|
|
328
|
+
value = str(config.value)
|
|
329
|
+
# Normalize line endings before stripping so cross-platform comparisons work.
|
|
330
|
+
actual_norm = output.replace("\r\n", "\n").strip()
|
|
331
|
+
expected_norm = value.replace("\r\n", "\n").strip()
|
|
332
|
+
actual_cmp = actual_norm if config.case_sensitive else actual_norm.lower()
|
|
333
|
+
expected_cmp = expected_norm if config.case_sensitive else expected_norm.lower()
|
|
334
|
+
passed = actual_cmp == expected_cmp
|
|
335
|
+
return AssertionResult(
|
|
336
|
+
type=config.type,
|
|
337
|
+
passed=passed,
|
|
338
|
+
expected=value,
|
|
339
|
+
actual=actual_norm[:200],
|
|
340
|
+
message=("output equals expected" if passed else "output does not equal expected"),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _check_json_valid(
|
|
345
|
+
config: AssertionConfig, output: str, latency_ms: float | None, cost_usd: float
|
|
346
|
+
) -> AssertionResult:
|
|
347
|
+
text = _strip_code_fence(output) if output else ""
|
|
348
|
+
if not text:
|
|
349
|
+
return AssertionResult(
|
|
350
|
+
type=config.type,
|
|
351
|
+
passed=False,
|
|
352
|
+
expected=None,
|
|
353
|
+
actual="(empty)",
|
|
354
|
+
message="output is empty",
|
|
355
|
+
)
|
|
356
|
+
try:
|
|
357
|
+
data = json.loads(text)
|
|
358
|
+
except json.JSONDecodeError as e:
|
|
359
|
+
return AssertionResult(
|
|
360
|
+
type=config.type,
|
|
361
|
+
passed=False,
|
|
362
|
+
expected=None,
|
|
363
|
+
actual=text[:100],
|
|
364
|
+
message=f"output is not valid JSON: {e.msg}",
|
|
365
|
+
)
|
|
366
|
+
return AssertionResult(
|
|
367
|
+
type=config.type,
|
|
368
|
+
passed=True,
|
|
369
|
+
expected=None,
|
|
370
|
+
actual=type(data).__name__,
|
|
371
|
+
message="output parsed as valid JSON",
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _check_json_schema(
|
|
376
|
+
config: AssertionConfig, output: str, latency_ms: float | None, cost_usd: float
|
|
377
|
+
) -> AssertionResult:
|
|
378
|
+
# Fresh import inside the function so tests can simulate a missing
|
|
379
|
+
# install by patching sys.modules / __import__.
|
|
380
|
+
try:
|
|
381
|
+
import jsonschema
|
|
382
|
+
except ImportError:
|
|
383
|
+
return AssertionResult(
|
|
384
|
+
type=config.type,
|
|
385
|
+
passed=False,
|
|
386
|
+
expected=config.value,
|
|
387
|
+
actual=None,
|
|
388
|
+
message="jsonschema not installed",
|
|
389
|
+
error="jsonschema not installed. Run: pip install cli-modelarium[schema]",
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
text = _strip_code_fence(output) if output else ""
|
|
393
|
+
if not text:
|
|
394
|
+
return AssertionResult(
|
|
395
|
+
type=config.type,
|
|
396
|
+
passed=False,
|
|
397
|
+
expected=config.value,
|
|
398
|
+
actual="(empty)",
|
|
399
|
+
message="output is empty",
|
|
400
|
+
)
|
|
401
|
+
try:
|
|
402
|
+
data = json.loads(text)
|
|
403
|
+
except json.JSONDecodeError as e:
|
|
404
|
+
return AssertionResult(
|
|
405
|
+
type=config.type,
|
|
406
|
+
passed=False,
|
|
407
|
+
expected=config.value,
|
|
408
|
+
actual=text[:100],
|
|
409
|
+
message=f"output is not valid JSON: {e.msg}",
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
schema = config.value
|
|
413
|
+
if not isinstance(schema, dict):
|
|
414
|
+
return AssertionResult(
|
|
415
|
+
type=config.type,
|
|
416
|
+
passed=False,
|
|
417
|
+
expected=schema,
|
|
418
|
+
actual=data,
|
|
419
|
+
message="schema must be an object",
|
|
420
|
+
error=f"schema value must be a JSON object, got {type(schema).__name__}",
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
try:
|
|
424
|
+
jsonschema.validate(data, schema)
|
|
425
|
+
except jsonschema.ValidationError as e:
|
|
426
|
+
path = "/".join(str(p) for p in e.absolute_path) or "<root>"
|
|
427
|
+
return AssertionResult(
|
|
428
|
+
type=config.type,
|
|
429
|
+
passed=False,
|
|
430
|
+
expected=schema,
|
|
431
|
+
actual=data,
|
|
432
|
+
message=f"schema violation at {path}: {e.message}",
|
|
433
|
+
)
|
|
434
|
+
except jsonschema.SchemaError as e:
|
|
435
|
+
# The schema ITSELF is malformed - this is a config error, not a fail.
|
|
436
|
+
return AssertionResult(
|
|
437
|
+
type=config.type,
|
|
438
|
+
passed=False,
|
|
439
|
+
expected=schema,
|
|
440
|
+
actual=data,
|
|
441
|
+
message="schema is malformed",
|
|
442
|
+
error=f"jsonschema schema error: {e.message}",
|
|
443
|
+
)
|
|
444
|
+
except Exception as e: # noqa: BLE001 - any other jsonschema quirk
|
|
445
|
+
return AssertionResult(
|
|
446
|
+
type=config.type,
|
|
447
|
+
passed=False,
|
|
448
|
+
expected=schema,
|
|
449
|
+
actual=data,
|
|
450
|
+
message="jsonschema raised unexpectedly",
|
|
451
|
+
error=f"{type(e).__name__}: {e}",
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
return AssertionResult(
|
|
455
|
+
type=config.type,
|
|
456
|
+
passed=True,
|
|
457
|
+
expected=schema,
|
|
458
|
+
actual=data,
|
|
459
|
+
message="output matches schema",
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _check_min_length_chars(
|
|
464
|
+
config: AssertionConfig, output: str, latency_ms: float | None, cost_usd: float
|
|
465
|
+
) -> AssertionResult:
|
|
466
|
+
try:
|
|
467
|
+
limit = int(config.value)
|
|
468
|
+
except (TypeError, ValueError):
|
|
469
|
+
return AssertionResult(
|
|
470
|
+
type=config.type,
|
|
471
|
+
passed=False,
|
|
472
|
+
expected=config.value,
|
|
473
|
+
actual=None,
|
|
474
|
+
message="min_length_chars value must be an integer",
|
|
475
|
+
error=f"value must be a number, got {config.value!r}",
|
|
476
|
+
)
|
|
477
|
+
actual = len(output)
|
|
478
|
+
passed = actual >= limit
|
|
479
|
+
return AssertionResult(
|
|
480
|
+
type=config.type,
|
|
481
|
+
passed=passed,
|
|
482
|
+
expected=limit,
|
|
483
|
+
actual=actual,
|
|
484
|
+
message=(
|
|
485
|
+
f"{actual} chars (min {limit})"
|
|
486
|
+
if passed
|
|
487
|
+
else f"only {actual} chars (need at least {limit})"
|
|
488
|
+
),
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _check_max_length_chars(
|
|
493
|
+
config: AssertionConfig, output: str, latency_ms: float | None, cost_usd: float
|
|
494
|
+
) -> AssertionResult:
|
|
495
|
+
try:
|
|
496
|
+
limit = int(config.value)
|
|
497
|
+
except (TypeError, ValueError):
|
|
498
|
+
return AssertionResult(
|
|
499
|
+
type=config.type,
|
|
500
|
+
passed=False,
|
|
501
|
+
expected=config.value,
|
|
502
|
+
actual=None,
|
|
503
|
+
message="max_length_chars value must be an integer",
|
|
504
|
+
error=f"value must be a number, got {config.value!r}",
|
|
505
|
+
)
|
|
506
|
+
actual = len(output)
|
|
507
|
+
passed = actual <= limit
|
|
508
|
+
return AssertionResult(
|
|
509
|
+
type=config.type,
|
|
510
|
+
passed=passed,
|
|
511
|
+
expected=limit,
|
|
512
|
+
actual=actual,
|
|
513
|
+
message=(
|
|
514
|
+
f"{actual} chars (max {limit})" if passed else f"{actual} chars exceeds max {limit}"
|
|
515
|
+
),
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _check_latency_under(
|
|
520
|
+
config: AssertionConfig, output: str, latency_ms: float | None, cost_usd: float
|
|
521
|
+
) -> AssertionResult:
|
|
522
|
+
try:
|
|
523
|
+
limit = float(config.value)
|
|
524
|
+
except (TypeError, ValueError):
|
|
525
|
+
return AssertionResult(
|
|
526
|
+
type=config.type,
|
|
527
|
+
passed=False,
|
|
528
|
+
expected=config.value,
|
|
529
|
+
actual=None,
|
|
530
|
+
message="latency_under value must be a number",
|
|
531
|
+
error=f"value must be a number, got {config.value!r}",
|
|
532
|
+
)
|
|
533
|
+
if latency_ms is None:
|
|
534
|
+
return AssertionResult(
|
|
535
|
+
type=config.type,
|
|
536
|
+
passed=False,
|
|
537
|
+
expected=limit,
|
|
538
|
+
actual=None,
|
|
539
|
+
message="latency unavailable",
|
|
540
|
+
error="latency was not measured for this call",
|
|
541
|
+
)
|
|
542
|
+
passed = latency_ms < limit
|
|
543
|
+
return AssertionResult(
|
|
544
|
+
type=config.type,
|
|
545
|
+
passed=passed,
|
|
546
|
+
expected=limit,
|
|
547
|
+
actual=round(latency_ms, 1),
|
|
548
|
+
message=(
|
|
549
|
+
f"{latency_ms:.1f}ms < {limit}ms"
|
|
550
|
+
if passed
|
|
551
|
+
else f"{latency_ms:.1f}ms exceeds {limit}ms limit"
|
|
552
|
+
),
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _check_cost_under(
|
|
557
|
+
config: AssertionConfig, output: str, latency_ms: float | None, cost_usd: float
|
|
558
|
+
) -> AssertionResult:
|
|
559
|
+
try:
|
|
560
|
+
limit = float(config.value)
|
|
561
|
+
except (TypeError, ValueError):
|
|
562
|
+
return AssertionResult(
|
|
563
|
+
type=config.type,
|
|
564
|
+
passed=False,
|
|
565
|
+
expected=config.value,
|
|
566
|
+
actual=None,
|
|
567
|
+
message="cost_under value must be a number",
|
|
568
|
+
error=f"value must be a number, got {config.value!r}",
|
|
569
|
+
)
|
|
570
|
+
passed = cost_usd < limit
|
|
571
|
+
return AssertionResult(
|
|
572
|
+
type=config.type,
|
|
573
|
+
passed=passed,
|
|
574
|
+
expected=limit,
|
|
575
|
+
actual=cost_usd,
|
|
576
|
+
message=(
|
|
577
|
+
f"${cost_usd:.6f} < ${limit:.6f}"
|
|
578
|
+
if passed
|
|
579
|
+
else f"${cost_usd:.6f} exceeds ${limit:.6f} limit"
|
|
580
|
+
),
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
# Dispatcher: assertion type value -> check function.
|
|
585
|
+
_CHECKERS: dict[str, Any] = {
|
|
586
|
+
AssertionType.CONTAINS.value: _check_contains,
|
|
587
|
+
AssertionType.NOT_CONTAINS.value: _check_not_contains,
|
|
588
|
+
AssertionType.REGEX.value: _check_regex,
|
|
589
|
+
AssertionType.EQUALS.value: _check_equals,
|
|
590
|
+
AssertionType.JSON_VALID.value: _check_json_valid,
|
|
591
|
+
AssertionType.JSON_SCHEMA.value: _check_json_schema,
|
|
592
|
+
AssertionType.MIN_LENGTH_CHARS.value: _check_min_length_chars,
|
|
593
|
+
AssertionType.MAX_LENGTH_CHARS.value: _check_max_length_chars,
|
|
594
|
+
AssertionType.LATENCY_UNDER.value: _check_latency_under,
|
|
595
|
+
AssertionType.COST_UNDER.value: _check_cost_under,
|
|
596
|
+
}
|
cli_modelarium/banner.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Startup banner for cli-modelarium.
|
|
2
|
+
|
|
3
|
+
Shown only on bare `cli-modelarium` invocation in an interactive terminal.
|
|
4
|
+
Rendered to stderr so it can never contaminate stdout data pipelines
|
|
5
|
+
(JSON/CSV/Markdown output that users pipe to jq, redirect to files, or
|
|
6
|
+
parse in CI).
|
|
7
|
+
|
|
8
|
+
ASCII art is hardcoded (generated once with the figlet 'standard' font -
|
|
9
|
+
no runtime dependency on pyfiglet). The gradient uses rich, which is
|
|
10
|
+
already a dependency.
|
|
11
|
+
|
|
12
|
+
Colors are the Tokyo Night brand palette matching the cli-modelarium logo:
|
|
13
|
+
#7AA2F7 primary blue, #BB9AF7 accent purple.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
from rich.text import Text
|
|
22
|
+
|
|
23
|
+
# "Cli Modelarium" rendered with the figlet 'standard' font.
|
|
24
|
+
# Generated once during development; hardcoded here (no pyfiglet at runtime).
|
|
25
|
+
_BANNER_ART = r"""
|
|
26
|
+
____ _ _ __ __ _ _ _
|
|
27
|
+
/ ___| (_) | \/ | ___ __| | ___| | __ _ _ __(_)_ _ _ __ ___
|
|
28
|
+
| | | | | | |\/| |/ _ \ / _` |/ _ \ |/ _` | '__| | | | | '_ ` _ \
|
|
29
|
+
| |___| | | | | | | (_) | (_| | __/ | (_| | | | | |_| | | | | | |
|
|
30
|
+
\____|_|_| |_| |_|\___/ \__,_|\___|_|\__,_|_| |_|\__,_|_| |_| |_|
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
# Tokyo Night brand palette (matches the logo wordmark).
|
|
34
|
+
_BLUE = (0x7A, 0xA2, 0xF7) # #7AA2F7
|
|
35
|
+
_PURPLE = (0xBB, 0x9A, 0xF7) # #BB9AF7
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _lerp(a: int, b: int, t: float) -> int:
|
|
39
|
+
"""Linear interpolation between two ints."""
|
|
40
|
+
return int(a + (b - a) * t)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _gradient_line(
|
|
44
|
+
line: str,
|
|
45
|
+
start: tuple[int, int, int],
|
|
46
|
+
end: tuple[int, int, int],
|
|
47
|
+
) -> Text:
|
|
48
|
+
"""Apply a horizontal RGB gradient across one line of text."""
|
|
49
|
+
text = Text()
|
|
50
|
+
n = max(len(line) - 1, 1)
|
|
51
|
+
for i, ch in enumerate(line):
|
|
52
|
+
t = i / n
|
|
53
|
+
r = _lerp(start[0], end[0], t)
|
|
54
|
+
g = _lerp(start[1], end[1], t)
|
|
55
|
+
b = _lerp(start[2], end[2], t)
|
|
56
|
+
text.append(ch, style=f"rgb({r},{g},{b})")
|
|
57
|
+
return text
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def should_show_banner() -> bool:
|
|
61
|
+
"""Return True only when the banner is safe to show.
|
|
62
|
+
|
|
63
|
+
The banner is safe only in an interactive terminal. When stdout is
|
|
64
|
+
piped, redirected, or running in CI, isatty() is False and we skip
|
|
65
|
+
the banner so it can never interfere with output or scripting.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
return sys.stdout.isatty()
|
|
69
|
+
except (AttributeError, ValueError):
|
|
70
|
+
# Defensive: if stdout is unusual (closed, replaced), don't show.
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def render_banner() -> None:
|
|
75
|
+
"""Render the cli-modelarium banner to stderr with a brand gradient.
|
|
76
|
+
|
|
77
|
+
Emitted to stderr (not stdout) for defense-in-depth: even if this were
|
|
78
|
+
ever called outside the bare-invocation branch, it physically cannot
|
|
79
|
+
land in a stdout data pipeline.
|
|
80
|
+
|
|
81
|
+
rich's Console respects NO_COLOR automatically; on no-color or legacy
|
|
82
|
+
terminals the gradient degrades to plain text gracefully.
|
|
83
|
+
"""
|
|
84
|
+
console = Console(stderr=True)
|
|
85
|
+
console.print()
|
|
86
|
+
for line in _BANNER_ART.strip("\n").splitlines():
|
|
87
|
+
console.print(_gradient_line(line, _BLUE, _PURPLE))
|
|
88
|
+
console.print()
|
|
89
|
+
tagline = Text()
|
|
90
|
+
tagline.append(
|
|
91
|
+
" Statistically rigorous LLM comparison",
|
|
92
|
+
style="bold rgb(122,162,247)",
|
|
93
|
+
)
|
|
94
|
+
console.print(tagline)
|
|
95
|
+
console.print(" climodelarium.com", style="rgb(187,154,247)")
|
|
96
|
+
console.print()
|