jevassert 0.1.0__tar.gz → 0.2.0__tar.gz

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 (38) hide show
  1. {jevassert-0.1.0 → jevassert-0.2.0}/AGENTS.md +14 -3
  2. {jevassert-0.1.0 → jevassert-0.2.0}/PKG-INFO +46 -4
  3. {jevassert-0.1.0 → jevassert-0.2.0}/README.md +42 -3
  4. {jevassert-0.1.0 → jevassert-0.2.0}/action.yml +16 -0
  5. {jevassert-0.1.0 → jevassert-0.2.0}/pyproject.toml +6 -0
  6. {jevassert-0.1.0 → jevassert-0.2.0}/src/jevassert/__init__.py +1 -1
  7. jevassert-0.2.0/src/jevassert/backends.py +115 -0
  8. {jevassert-0.1.0 → jevassert-0.2.0}/src/jevassert/cli.py +76 -11
  9. {jevassert-0.1.0 → jevassert-0.2.0}/src/jevassert/metrics.py +15 -4
  10. {jevassert-0.1.0 → jevassert-0.2.0}/src/jevassert/report.py +10 -8
  11. jevassert-0.2.0/tests/test_backends.py +256 -0
  12. jevassert-0.2.0/uv.lock +845 -0
  13. jevassert-0.1.0/uv.lock +0 -325
  14. {jevassert-0.1.0 → jevassert-0.2.0}/.github/workflows/ci.yml +0 -0
  15. {jevassert-0.1.0 → jevassert-0.2.0}/.github/workflows/publish.yml +0 -0
  16. {jevassert-0.1.0 → jevassert-0.2.0}/.gitignore +0 -0
  17. {jevassert-0.1.0 → jevassert-0.2.0}/LICENSE +0 -0
  18. {jevassert-0.1.0 → jevassert-0.2.0}/examples/demo-relevance/cases.jsonl +0 -0
  19. {jevassert-0.1.0 → jevassert-0.2.0}/examples/demo-relevance/gates.yaml +0 -0
  20. {jevassert-0.1.0 → jevassert-0.2.0}/examples/demo-relevance/pack.yaml +0 -0
  21. {jevassert-0.1.0 → jevassert-0.2.0}/examples/demo-relevance/predictions.jsonl +0 -0
  22. {jevassert-0.1.0 → jevassert-0.2.0}/examples/demo-triage/cases.jsonl +0 -0
  23. {jevassert-0.1.0 → jevassert-0.2.0}/examples/demo-triage/gates.yaml +0 -0
  24. {jevassert-0.1.0 → jevassert-0.2.0}/examples/demo-triage/pack.yaml +0 -0
  25. {jevassert-0.1.0 → jevassert-0.2.0}/examples/demo-triage/predictions-run2.jsonl +0 -0
  26. {jevassert-0.1.0 → jevassert-0.2.0}/examples/demo-triage/predictions-shuffled.jsonl +0 -0
  27. {jevassert-0.1.0 → jevassert-0.2.0}/examples/demo-triage/predictions.jsonl +0 -0
  28. {jevassert-0.1.0 → jevassert-0.2.0}/src/jevassert/__main__.py +0 -0
  29. {jevassert-0.1.0 → jevassert-0.2.0}/src/jevassert/client.py +0 -0
  30. {jevassert-0.1.0 → jevassert-0.2.0}/src/jevassert/compare.py +0 -0
  31. {jevassert-0.1.0 → jevassert-0.2.0}/src/jevassert/packs.py +0 -0
  32. {jevassert-0.1.0 → jevassert-0.2.0}/src/jevassert/runner.py +0 -0
  33. {jevassert-0.1.0 → jevassert-0.2.0}/tests/conftest.py +0 -0
  34. {jevassert-0.1.0 → jevassert-0.2.0}/tests/helpers.py +0 -0
  35. {jevassert-0.1.0 → jevassert-0.2.0}/tests/test_cli.py +0 -0
  36. {jevassert-0.1.0 → jevassert-0.2.0}/tests/test_metrics.py +0 -0
  37. {jevassert-0.1.0 → jevassert-0.2.0}/tests/test_packs.py +0 -0
  38. {jevassert-0.1.0 → jevassert-0.2.0}/tests/test_runner.py +0 -0
@@ -13,11 +13,16 @@ Public repo: https://github.com/dtduc-git/jevassert · Apache-2.0.
13
13
  - `src/jevassert/client.py` — one HTTP client for `/v1/systemone`, retries on
14
14
  429/5xx, injectable transport. The API key comes from `TYPESAFE_API_KEY`
15
15
  only; never accept literal keys on the CLI, never log them.
16
+ - `src/jevassert/backends.py` — `AdapterClient`: same interface, but records a
17
+ general-purpose LLM through the official `system-one-adapter`
18
+ (`--backend openai|anthropic`, optional extra `jevassert[adapter]`). Used by
19
+ the jev-packs benchmark; maps adapter errors onto `JevError` (auth fail-fast).
16
20
  - `src/jevassert/runner.py` — `record` (concurrent, per-case, `model=` override)
17
21
  and predictions JSONL read/write. One line per case, stable case order.
18
22
  - `src/jevassert/metrics.py` — items, accuracy, ECE (equal-mass bins), Brier
19
- (Noul), coverage table, author-threshold coverage, cost/latency, gates.
20
- Definitions are in the module docstring — keep them.
23
+ (Noul), coverage table, author-threshold coverage, cost/latency (prices
24
+ injectable for non-Jev backends), gates. Definitions are in the module
25
+ docstring — keep them.
21
26
  - `src/jevassert/compare.py` — paired comparison + exact McNemar (no scipy).
22
27
  - `src/jevassert/report.py` — markdown report (spec `evidence.md`) + JUnit XML.
23
28
  - `src/jevassert/cli.py` — `record | check | compare`, exit 0/1/2. `check`
@@ -67,4 +72,10 @@ Reconcile with SPEC was done 2026-09-19. Status:
67
72
  8. ~~Record evidence for the jev-packs registry packs~~ done 2026-09-19:
68
73
  9 packs verified (2,990 cases / 6,430 items), evidence regenerated from the
69
74
  committed recordings, jev-packs validator green.
70
- 9. Publish: GitHub repo + push, PyPI trusted publisher, Action listing.
75
+ 9. ~~Publish: GitHub repo + push~~ done 2026-09-19; ~~PyPI trusted publisher +
76
+ `v0.1.0` release~~ done (PyPI `jevassert` 0.1.0, `uvx jevassert` verified).
77
+ Action Marketplace listing still open.
78
+ 10. **0.2.0 (unreleased)**: adapter backends (`--backend openai|anthropic`),
79
+ `check --input-price/--output-price`, reports the recorded models instead
80
+ of `pack.recorded_model`, action.yml price inputs + optional `[adapter]`
81
+ extra. Release before merging jev-packs result columns (CI pins 0.2.0).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: jevassert
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Record/replay regression tests for Jev (TypeSafe System One) question packs — accuracy, calibration and cost gates in CI.
5
5
  Project-URL: Homepage, https://github.com/dtduc-git/jevassert
6
6
  Project-URL: Issues, https://github.com/dtduc-git/jevassert/issues
@@ -22,11 +22,15 @@ Classifier: Topic :: Software Development :: Testing
22
22
  Requires-Python: >=3.10
23
23
  Requires-Dist: httpx>=0.27
24
24
  Requires-Dist: pyyaml>=6.0
25
+ Provides-Extra: adapter
26
+ Requires-Dist: boto3>=1.34; extra == 'adapter'
27
+ Requires-Dist: system-one-adapter[anthropic,openai]<0.3,>=0.2; extra == 'adapter'
25
28
  Description-Content-Type: text/markdown
26
29
 
27
30
  # jevassert
28
31
 
29
32
  [![CI](https://github.com/dtduc-git/jevassert/actions/workflows/ci.yml/badge.svg)](https://github.com/dtduc-git/jevassert/actions/workflows/ci.yml)
33
+ [![PyPI](https://img.shields.io/pypi/v/jevassert.svg)](https://pypi.org/project/jevassert/)
30
34
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
31
35
 
32
36
  Regression tests for [Jev](https://typesafe.ai) question packs: assert
@@ -63,6 +67,42 @@ uvx jevassert record examples/demo-triage -o triage-next.jsonl
63
67
  uvx jevassert compare examples/demo-triage --a triage.jsonl --b triage-next.jsonl
64
68
  ```
65
69
 
70
+ ## Benchmarking other backends
71
+
72
+ The same pack, runner and metrics can record a **general-purpose LLM** instead
73
+ of Jev, through TypeSafe's official
74
+ [system-one-adapter](https://github.com/typesafe-ai/system-one-adapter-python):
75
+ same questions, same golden cases, no TypeSafe key involved. This is how the
76
+ `results/` baselines in [jev-packs](https://github.com/dtduc-git/jev-packs) are
77
+ produced — one ground truth, several backends.
78
+
79
+ ```sh
80
+ pip install 'jevassert[adapter]' # optional extra; brings the provider SDKs
81
+
82
+ # any OpenAI-compatible endpoint (Ollama, vLLM, gateways)
83
+ uvx jevassert record packs/sms-spam -o sms-qwen.jsonl \
84
+ --backend openai --model qwen2.5:7b --base-url http://localhost:11434/v1
85
+
86
+ # Claude (needs ANTHROPIC_API_KEY)
87
+ uvx jevassert record packs/sms-spam -o sms-claude.jsonl \
88
+ --backend anthropic --model claude-haiku-4-5
89
+ ```
90
+
91
+ Adapter recordings use structured outputs in probabilities mode, normalize the
92
+ distributions and allow two corrective retries on malformed output — the same
93
+ settings for every LLM backend, so comparisons are like for like. Cost is
94
+ priced at `check` time, not record time:
95
+
96
+ ```sh
97
+ # self-hosted endpoint: free
98
+ uvx jevassert check packs/sms-spam -p sms-qwen.jsonl --input-price 0 --output-price 0
99
+ # hosted API at list price
100
+ uvx jevassert check packs/sms-spam -p sms-claude.jsonl --input-price 1 --output-price 5
101
+ ```
102
+
103
+ `check` reports the models actually present in the recording (not the pack's
104
+ pinned version) and prices both input and output tokens.
105
+
66
106
  ## Packs
67
107
 
68
108
  A pack follows **spec v0** (canonical:
@@ -150,8 +190,8 @@ the Action enforces the gates on every pull request.
150
190
 
151
191
  | command | what it does |
152
192
  |---|---|
153
- | `jevassert record PACK -o FILE [--model M] [--resume] [--rpm N] [--dry-run] [--shuffle-options SEED] [--repeat N]` | call Jev for every case, write predictions JSONL |
154
- | `jevassert check PACK -p FILE [--failures] [--bootstrap N] [--target-precision P] [--partition dev\|test]` | compute metrics, evaluate gates, exit 0/1/2 |
193
+ | `jevassert record PACK -o FILE [--model M] [--backend typesafe\|openai\|anthropic] [--base-url URL] [--resume] [--rpm N] [--dry-run] [--shuffle-options SEED] [--repeat N]` | call the backend for every case, write predictions JSONL |
194
+ | `jevassert check PACK -p FILE [--failures] [--bootstrap N] [--target-precision P] [--input-price X] [--output-price Y] [--partition dev\|test]` | compute metrics, evaluate gates, exit 0/1/2 |
155
195
  | `jevassert compare PACK --a A --b B` | paired accuracy deltas + exact McNemar p-value |
156
196
 
157
197
  `record` extras: `--dry-run` estimates tokens/cost from the pack without sending
@@ -166,7 +206,9 @@ decision count across rounds.
166
206
  on test without fooling yourself.
167
207
 
168
208
  Environment: `TYPESAFE_API_KEY` (record only), `TYPESAFE_BASE_URL` (override
169
- the endpoint, e.g. a local Jev-compatible replica).
209
+ the endpoint, e.g. a local Jev-compatible replica), `OPENAI_API_KEY` (only when
210
+ the `openai` adapter backend points at a hosted endpoint), `ANTHROPIC_API_KEY`
211
+ (`anthropic` adapter backend).
170
212
 
171
213
  ## Reading the numbers
172
214
 
@@ -1,6 +1,7 @@
1
1
  # jevassert
2
2
 
3
3
  [![CI](https://github.com/dtduc-git/jevassert/actions/workflows/ci.yml/badge.svg)](https://github.com/dtduc-git/jevassert/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/jevassert.svg)](https://pypi.org/project/jevassert/)
4
5
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
5
6
 
6
7
  Regression tests for [Jev](https://typesafe.ai) question packs: assert
@@ -37,6 +38,42 @@ uvx jevassert record examples/demo-triage -o triage-next.jsonl
37
38
  uvx jevassert compare examples/demo-triage --a triage.jsonl --b triage-next.jsonl
38
39
  ```
39
40
 
41
+ ## Benchmarking other backends
42
+
43
+ The same pack, runner and metrics can record a **general-purpose LLM** instead
44
+ of Jev, through TypeSafe's official
45
+ [system-one-adapter](https://github.com/typesafe-ai/system-one-adapter-python):
46
+ same questions, same golden cases, no TypeSafe key involved. This is how the
47
+ `results/` baselines in [jev-packs](https://github.com/dtduc-git/jev-packs) are
48
+ produced — one ground truth, several backends.
49
+
50
+ ```sh
51
+ pip install 'jevassert[adapter]' # optional extra; brings the provider SDKs
52
+
53
+ # any OpenAI-compatible endpoint (Ollama, vLLM, gateways)
54
+ uvx jevassert record packs/sms-spam -o sms-qwen.jsonl \
55
+ --backend openai --model qwen2.5:7b --base-url http://localhost:11434/v1
56
+
57
+ # Claude (needs ANTHROPIC_API_KEY)
58
+ uvx jevassert record packs/sms-spam -o sms-claude.jsonl \
59
+ --backend anthropic --model claude-haiku-4-5
60
+ ```
61
+
62
+ Adapter recordings use structured outputs in probabilities mode, normalize the
63
+ distributions and allow two corrective retries on malformed output — the same
64
+ settings for every LLM backend, so comparisons are like for like. Cost is
65
+ priced at `check` time, not record time:
66
+
67
+ ```sh
68
+ # self-hosted endpoint: free
69
+ uvx jevassert check packs/sms-spam -p sms-qwen.jsonl --input-price 0 --output-price 0
70
+ # hosted API at list price
71
+ uvx jevassert check packs/sms-spam -p sms-claude.jsonl --input-price 1 --output-price 5
72
+ ```
73
+
74
+ `check` reports the models actually present in the recording (not the pack's
75
+ pinned version) and prices both input and output tokens.
76
+
40
77
  ## Packs
41
78
 
42
79
  A pack follows **spec v0** (canonical:
@@ -124,8 +161,8 @@ the Action enforces the gates on every pull request.
124
161
 
125
162
  | command | what it does |
126
163
  |---|---|
127
- | `jevassert record PACK -o FILE [--model M] [--resume] [--rpm N] [--dry-run] [--shuffle-options SEED] [--repeat N]` | call Jev for every case, write predictions JSONL |
128
- | `jevassert check PACK -p FILE [--failures] [--bootstrap N] [--target-precision P] [--partition dev\|test]` | compute metrics, evaluate gates, exit 0/1/2 |
164
+ | `jevassert record PACK -o FILE [--model M] [--backend typesafe\|openai\|anthropic] [--base-url URL] [--resume] [--rpm N] [--dry-run] [--shuffle-options SEED] [--repeat N]` | call the backend for every case, write predictions JSONL |
165
+ | `jevassert check PACK -p FILE [--failures] [--bootstrap N] [--target-precision P] [--input-price X] [--output-price Y] [--partition dev\|test]` | compute metrics, evaluate gates, exit 0/1/2 |
129
166
  | `jevassert compare PACK --a A --b B` | paired accuracy deltas + exact McNemar p-value |
130
167
 
131
168
  `record` extras: `--dry-run` estimates tokens/cost from the pack without sending
@@ -140,7 +177,9 @@ decision count across rounds.
140
177
  on test without fooling yourself.
141
178
 
142
179
  Environment: `TYPESAFE_API_KEY` (record only), `TYPESAFE_BASE_URL` (override
143
- the endpoint, e.g. a local Jev-compatible replica).
180
+ the endpoint, e.g. a local Jev-compatible replica), `OPENAI_API_KEY` (only when
181
+ the `openai` adapter backend points at a hosted endpoint), `ANTHROPIC_API_KEY`
182
+ (`anthropic` adapter backend).
144
183
 
145
184
  ## Reading the numbers
146
185
 
@@ -25,6 +25,14 @@ inputs:
25
25
  description: Where to write the markdown report (empty = skip)
26
26
  required: false
27
27
  default: ""
28
+ input-price:
29
+ description: USD per million input tokens for the cost line (default 0.042, Jev list price)
30
+ required: false
31
+ default: ""
32
+ output-price:
33
+ description: USD per million output tokens for the cost line (default 0 = free)
34
+ required: false
35
+ default: ""
28
36
 
29
37
  runs:
30
38
  using: composite
@@ -39,6 +47,8 @@ runs:
39
47
  JUNIT: ${{ inputs.junit }}
40
48
  REPORT: ${{ inputs.report }}
41
49
  VERSION: ${{ inputs.version }}
50
+ INPUT_PRICE: ${{ inputs.input-price }}
51
+ OUTPUT_PRICE: ${{ inputs.output-price }}
42
52
  run: |
43
53
  if [ "$VERSION" = "latest" ]; then
44
54
  package="jevassert"
@@ -49,4 +59,10 @@ runs:
49
59
  if [ -n "$REPORT" ]; then
50
60
  args+=(--report "$REPORT")
51
61
  fi
62
+ if [ -n "$INPUT_PRICE" ]; then
63
+ args+=(--input-price "$INPUT_PRICE")
64
+ fi
65
+ if [ -n "$OUTPUT_PRICE" ]; then
66
+ args+=(--output-price "$OUTPUT_PRICE")
67
+ fi
52
68
  uvx "$package" "${args[@]}"
@@ -34,6 +34,12 @@ dependencies = [
34
34
  "pyyaml>=6.0",
35
35
  ]
36
36
 
37
+ [project.optional-dependencies]
38
+ adapter = [
39
+ "system-one-adapter[openai,anthropic]>=0.2,<0.3",
40
+ "boto3>=1.34",
41
+ ]
42
+
37
43
  [project.scripts]
38
44
  jevassert = "jevassert.cli:main"
39
45
 
@@ -1,3 +1,3 @@
1
1
  """jevassert — record/replay regression tests for Jev question packs."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -0,0 +1,115 @@
1
+ """Recording backends: the TypeSafe API, or any LLM via ``system-one-adapter``.
2
+
3
+ ``record --backend openai|anthropic`` runs the same pack, the same runner and
4
+ the same metrics against a general-purpose LLM through the official adapter
5
+ (https://github.com/typesafe-ai/system-one-adapter-python). Used to record
6
+ baseline evidence for the jev-packs benchmark, and to compare a candidate
7
+ endpoint against Jev on identical ground truth.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import time
14
+ from typing import Any
15
+
16
+ from .client import JevError
17
+
18
+
19
+ class AdapterClient:
20
+ """``JevClient``-shaped client backed by an LLM through system-one-adapter.
21
+
22
+ ``backend`` selects the adapter provider: ``openai`` covers any
23
+ OpenAI-compatible endpoint (Ollama, vLLM, gateways; set ``base_url``),
24
+ ``anthropic`` calls Claude directly. Recording never touches the TypeSafe
25
+ API, so no TypeSafe key is needed.
26
+ """
27
+
28
+ def __init__(self, backend: str, model: str, base_url: str | None = None) -> None:
29
+ try:
30
+ from system_one_adapter import RetryPolicy, SystemOneAdapterClient
31
+ except ImportError as exc:
32
+ raise JevError(
33
+ "adapter backends need system-one-adapter: pip install 'jevassert[adapter]'"
34
+ ) from exc
35
+ self.backend = backend
36
+ self.model_name = model
37
+ self.api_key = "unused" # runner.record only checks presence
38
+ self._provider = _build_provider(backend, model, base_url)
39
+ self._client = SystemOneAdapterClient(
40
+ structured_outputs=True,
41
+ llm_answer_mode="probabilities",
42
+ normalize_probabilities=True,
43
+ n_retry_malformed_structure=2,
44
+ retry=RetryPolicy(max_retries=3),
45
+ )
46
+
47
+ def system_one(
48
+ self, state: Any, questions: dict[str, Any], model: str = ""
49
+ ) -> tuple[dict[str, Any], float]:
50
+ """Evaluate questions against the configured LLM; returns (response, latency_ms)."""
51
+ started = time.perf_counter()
52
+ try:
53
+ response = self._client.system_one(state, questions, model=self._provider)
54
+ except Exception as exc: # adapter raises SDK error types
55
+ raise _as_jev_error(exc) from exc
56
+ return response.model_dump(), (time.perf_counter() - started) * 1000
57
+
58
+ def close(self) -> None:
59
+ self._client.close()
60
+
61
+
62
+ def _build_provider(backend: str, model: str, base_url: str | None) -> Any:
63
+ if backend == "openai":
64
+ from system_one_adapter.providers.openai import OpenAIProvider
65
+
66
+ return OpenAIProvider(
67
+ model, base_url=base_url, api_key=os.environ.get("OPENAI_API_KEY") or "not-needed"
68
+ )
69
+ if backend == "anthropic":
70
+ if not os.environ.get("ANTHROPIC_API_KEY"):
71
+ raise JevError("ANTHROPIC_API_KEY is not set (required for --backend anthropic)")
72
+ from system_one_adapter.providers.anthropic import AnthropicProvider
73
+
74
+ return AnthropicProvider(model)
75
+ if backend == "bedrock":
76
+ return _build_bedrock_provider(model)
77
+ raise ValueError(f"unknown backend {backend!r} (expected openai, anthropic or bedrock)")
78
+
79
+
80
+ def _build_bedrock_provider(model: str) -> Any:
81
+ """Claude through AWS Bedrock: the adapter's AnthropicProvider over AnthropicBedrock.
82
+
83
+ Credentials come from the standard AWS chain (``AWS_PROFILE``/``AWS_REGION``
84
+ or instance roles). Newer models need an inference profile ID, e.g.
85
+ ``global.anthropic.claude-haiku-4-5-20251001-v1:0``.
86
+ """
87
+ import anthropic
88
+ from system_one_adapter.providers.anthropic import AnthropicProvider
89
+
90
+ class BedrockProvider(AnthropicProvider): # type: ignore[misc, valid-type]
91
+ def __init__(self, model_name: str) -> None:
92
+ super().__init__(model_name)
93
+ self._client = anthropic.AnthropicBedrock(max_retries=0)
94
+
95
+ return BedrockProvider(model)
96
+
97
+
98
+ def _as_jev_error(exc: Exception) -> JevError:
99
+ """Map adapter/SDK failures onto ``JevError`` so fail-fast paths keep working."""
100
+ status = getattr(exc, "status", None)
101
+ if status is None:
102
+ text = str(exc).lower()
103
+ if any(
104
+ marker in text
105
+ for marker in (
106
+ "api key",
107
+ "authentication",
108
+ "unauthorized",
109
+ "credential",
110
+ "access denied",
111
+ "accessdenied",
112
+ )
113
+ ):
114
+ status = 401
115
+ return JevError(str(exc), status=status)
@@ -11,6 +11,7 @@ from pathlib import Path
11
11
  from typing import Any
12
12
 
13
13
  from . import __version__
14
+ from .backends import AdapterClient
14
15
  from .client import JevClient, JevError
15
16
  from .compare import CompareResult
16
17
  from .compare import compare as compare_recordings
@@ -61,7 +62,21 @@ def _build_parser() -> argparse.ArgumentParser:
61
62
  default=None,
62
63
  help="model version to record (default: pack.tested or jev-latest)",
63
64
  )
64
- record_parser.add_argument("--base-url", default=None, help="override TYPESAFE_BASE_URL")
65
+ record_parser.add_argument(
66
+ "--backend",
67
+ choices=("typesafe", "openai", "anthropic", "bedrock"),
68
+ default="typesafe",
69
+ help="typesafe = TypeSafe API; openai = any OpenAI-compatible endpoint "
70
+ "(--base-url, e.g. Ollama); anthropic = Claude API; bedrock = Claude via "
71
+ "AWS Bedrock (AWS_PROFILE/AWS_REGION, use an inference profile id). The "
72
+ "last three go through system-one-adapter (pip install 'jevassert[adapter]') "
73
+ "and require --model",
74
+ )
75
+ record_parser.add_argument(
76
+ "--base-url",
77
+ default=None,
78
+ help="override TYPESAFE_BASE_URL (or the OpenAI-compatible endpoint for --backend openai)",
79
+ )
65
80
  record_parser.add_argument(
66
81
  "--resume",
67
82
  action="store_true",
@@ -113,6 +128,18 @@ def _build_parser() -> argparse.ArgumentParser:
113
128
  default=None,
114
129
  help="suggest the highest-coverage threshold that reaches this precision",
115
130
  )
131
+ check_parser.add_argument(
132
+ "--input-price",
133
+ type=float,
134
+ default=INPUT_USD_PER_MTOK,
135
+ help=f"USD per million input tokens (default {INPUT_USD_PER_MTOK}, the Jev list price)",
136
+ )
137
+ check_parser.add_argument(
138
+ "--output-price",
139
+ type=float,
140
+ default=0.0,
141
+ help="USD per million output tokens (default 0: Jev output tokens are free)",
142
+ )
116
143
  check_parser.add_argument(
117
144
  "--partition",
118
145
  choices=("all", "dev", "test"),
@@ -154,7 +181,13 @@ def _cmd_record(args: argparse.Namespace) -> int:
154
181
  print("jevassert: error: --repeat must be >= 1", file=sys.stderr)
155
182
  return 2
156
183
 
157
- client = JevClient(base_url=args.base_url)
184
+ if args.backend == "typesafe":
185
+ client: JevClient | AdapterClient = JevClient(base_url=args.base_url)
186
+ else:
187
+ if not args.model:
188
+ print(f"jevassert: error: --backend {args.backend} requires --model", file=sys.stderr)
189
+ return 2
190
+ client = AdapterClient(args.backend, args.model, base_url=args.base_url)
158
191
  base_out = Path(args.out)
159
192
  total_errors = 0
160
193
  try:
@@ -254,27 +287,48 @@ def _cmd_check(args: argparse.Namespace) -> int:
254
287
  f"(seed {args.partition_seed}, ratio {args.partition_ratio})",
255
288
  file=sys.stderr,
256
289
  )
257
- overall = compute(pack, predictions, bootstrap=args.bootstrap)
290
+ overall = compute(
291
+ pack,
292
+ predictions,
293
+ bootstrap=args.bootstrap,
294
+ input_usd_per_mtok=args.input_price,
295
+ output_usd_per_mtok=args.output_price,
296
+ )
258
297
  gates = evaluate_gates(pack, overall)
259
298
  suggestion = None
260
299
  if args.target_precision is not None:
261
300
  items, _, _ = build_items(pack, predictions)
262
301
  suggestion = suggest_threshold(items, args.target_precision)
302
+ models = _recorded_models(predictions)
263
303
 
264
304
  if args.junit:
265
305
  Path(args.junit).write_text(render_junit(pack, gates), encoding="utf-8")
266
306
  if args.report:
267
307
  Path(args.report).write_text(
268
- render_markdown(pack, overall, gates, suggestion, args.target_precision),
308
+ render_markdown(pack, overall, gates, suggestion, args.target_precision, models),
269
309
  encoding="utf-8",
270
310
  )
271
311
 
272
312
  if args.json:
273
- print(json.dumps(_payload(pack, overall, gates, suggestion), indent=2, ensure_ascii=False))
313
+ payload = _payload(pack, overall, gates, suggestion)
314
+ payload["recorded_models"] = models
315
+ payload["pricing"] = {
316
+ "input_usd_per_mtok": args.input_price,
317
+ "output_usd_per_mtok": args.output_price,
318
+ }
319
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
274
320
  else:
275
321
  print(
276
322
  _human_summary(
277
- pack, overall, gates, args.predictions, suggestion, args.target_precision
323
+ pack,
324
+ overall,
325
+ gates,
326
+ args.predictions,
327
+ suggestion,
328
+ args.target_precision,
329
+ models,
330
+ args.input_price,
331
+ args.output_price,
278
332
  )
279
333
  )
280
334
  if args.failures:
@@ -414,6 +468,12 @@ def _payload(
414
468
  }
415
469
 
416
470
 
471
+ def _recorded_models(predictions: dict[str, dict[str, Any]]) -> list[str]:
472
+ return sorted(
473
+ {record["model"] for record in predictions.values() if isinstance(record.get("model"), str)}
474
+ )
475
+
476
+
417
477
  def _human_summary(
418
478
  pack: Pack,
419
479
  overall: OverallMetrics,
@@ -421,12 +481,14 @@ def _human_summary(
421
481
  predictions_path: str,
422
482
  suggestion: ThresholdSuggestion | None = None,
423
483
  target_precision: float | None = None,
484
+ models: list[str] | None = None,
485
+ input_price: float = INPUT_USD_PER_MTOK,
486
+ output_price: float = 0.0,
424
487
  ) -> str:
425
488
  lines: list[str] = []
426
489
  tested = f", tested {pack.tested}" if pack.tested else ", provisional"
427
- lines.append(
428
- f"jevassert — {pack.id} v{pack.version} (record model {pack.record_model}{tested})"
429
- )
490
+ record_model = ", ".join(models) if models else pack.record_model
491
+ lines.append(f"jevassert — {pack.id} v{pack.version} (record model {record_model}{tested})")
430
492
  lines.append(
431
493
  f"recording: {predictions_path} — {overall.n_cases} cases, "
432
494
  f"{overall.case_errors} errors, {overall.missing_items} missing, {overall.n_items} items"
@@ -457,9 +519,12 @@ def _human_summary(
457
519
  "(see README: Reading the numbers)"
458
520
  )
459
521
  if overall.cost_per_case_usd is not None:
522
+ price = f"${input_price}/M input tokens"
523
+ if output_price:
524
+ price = f"${input_price}/M input + ${output_price}/M output tokens"
460
525
  lines.append(
461
526
  f"cost/case ${overall.cost_per_case_usd:.6f} "
462
- f"(total ${overall.total_cost_usd:.4f} at ${0.042}/M input tokens)"
527
+ f"(total ${overall.total_cost_usd:.4f} at {price})"
463
528
  )
464
529
  if overall.p95_latency_ms is not None:
465
530
  lines.append(
@@ -490,7 +555,7 @@ def _human_summary(
490
555
  lines.append("")
491
556
  lines.append("gates:")
492
557
  if not gates:
493
- lines.append(" (none declared in pack.yaml)")
558
+ lines.append(" (none declared in gates.yaml)")
494
559
  for gate in gates:
495
560
  mark = "PASS" if gate.ok else ("SKIP" if gate.ok is None else "FAIL")
496
561
  lines.append(f" {mark} {gate.gate}: {gate.detail}")
@@ -207,6 +207,8 @@ def compute(
207
207
  predictions: dict[str, dict[str, Any]],
208
208
  bootstrap: int = 1000,
209
209
  seed: int = 0,
210
+ input_usd_per_mtok: float = INPUT_USD_PER_MTOK,
211
+ output_usd_per_mtok: float = 0.0,
210
212
  ) -> OverallMetrics:
211
213
  items, case_errors, missing_items = build_items(pack, predictions)
212
214
 
@@ -249,18 +251,27 @@ def compute(
249
251
  all_correct = [item.correct for item in items]
250
252
 
251
253
  total_input_tokens = 0
254
+ total_output_tokens = 0
252
255
  saw_usage = False
253
256
  latencies: list[float] = []
254
257
  for case in pack.cases:
255
258
  record = predictions.get(case.id) or {}
256
259
  usage = record.get("usage")
257
- if isinstance(usage, dict) and isinstance(usage.get("input_tokens"), int | float):
258
- total_input_tokens += int(usage["input_tokens"])
259
- saw_usage = True
260
+ if isinstance(usage, dict):
261
+ if isinstance(usage.get("input_tokens"), int | float):
262
+ total_input_tokens += int(usage["input_tokens"])
263
+ saw_usage = True
264
+ if isinstance(usage.get("output_tokens"), int | float):
265
+ total_output_tokens += int(usage["output_tokens"])
260
266
  if isinstance(record.get("latency_ms"), int | float):
261
267
  latencies.append(float(record["latency_ms"]))
262
268
 
263
- total_cost = (total_input_tokens * INPUT_USD_PER_MTOK / 1_000_000) if saw_usage else None
269
+ total_cost = (
270
+ (total_input_tokens * input_usd_per_mtok + total_output_tokens * output_usd_per_mtok)
271
+ / 1_000_000
272
+ if saw_usage
273
+ else None
274
+ )
264
275
  cost_per_case = (total_cost / len(pack.cases)) if total_cost is not None else None
265
276
 
266
277
  return OverallMetrics(
@@ -14,18 +14,20 @@ def render_markdown(
14
14
  gates: list[GateResult],
15
15
  suggestion: ThresholdSuggestion | None = None,
16
16
  target_precision: float | None = None,
17
+ recorded_models: list[str] | None = None,
17
18
  ) -> str:
18
19
  lines: list[str] = []
19
20
  lines.append(f"# jevassert report — {pack.id} v{pack.version}")
20
21
  lines.append("")
21
- lines.append(
22
- f"- model: `{pack.record_model}`"
23
- + (
24
- f" (recorded against `{pack.tested}`)"
25
- if pack.tested
26
- else " (provisional no pinned version)"
27
- )
28
- )
22
+ models = list(recorded_models or ([pack.tested] if pack.tested else []))
23
+ model_label = ", ".join(f"`{m}`" for m in models) if models else f"`{pack.record_model}`"
24
+ if models and pack.tested and models == [pack.tested]:
25
+ suffix = f" (recorded against `{pack.tested}`)"
26
+ elif pack.tested:
27
+ suffix = f" (pack pinned to `{pack.tested}`)"
28
+ else:
29
+ suffix = " (provisional — no pinned version)"
30
+ lines.append(f"- model: {model_label}{suffix}")
29
31
  lines.append(
30
32
  f"- cases: {overall.n_cases} ({overall.case_errors} errors, "
31
33
  f"{overall.missing_items} missing answers)"