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,6 @@
1
+ """Cli Modelarium - compare LLM outputs side-by-side from your terminal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.3"
6
+ __all__ = ["__version__"]
@@ -0,0 +1,8 @@
1
+ """Entry point for `python -m cli_modelarium`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from cli_modelarium.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
@@ -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
+ }
@@ -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()