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.
- {jevassert-0.1.0 → jevassert-0.2.0}/AGENTS.md +14 -3
- {jevassert-0.1.0 → jevassert-0.2.0}/PKG-INFO +46 -4
- {jevassert-0.1.0 → jevassert-0.2.0}/README.md +42 -3
- {jevassert-0.1.0 → jevassert-0.2.0}/action.yml +16 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/pyproject.toml +6 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/src/jevassert/__init__.py +1 -1
- jevassert-0.2.0/src/jevassert/backends.py +115 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/src/jevassert/cli.py +76 -11
- {jevassert-0.1.0 → jevassert-0.2.0}/src/jevassert/metrics.py +15 -4
- {jevassert-0.1.0 → jevassert-0.2.0}/src/jevassert/report.py +10 -8
- jevassert-0.2.0/tests/test_backends.py +256 -0
- jevassert-0.2.0/uv.lock +845 -0
- jevassert-0.1.0/uv.lock +0 -325
- {jevassert-0.1.0 → jevassert-0.2.0}/.github/workflows/ci.yml +0 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/.github/workflows/publish.yml +0 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/.gitignore +0 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/LICENSE +0 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/examples/demo-relevance/cases.jsonl +0 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/examples/demo-relevance/gates.yaml +0 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/examples/demo-relevance/pack.yaml +0 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/examples/demo-relevance/predictions.jsonl +0 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/examples/demo-triage/cases.jsonl +0 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/examples/demo-triage/gates.yaml +0 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/examples/demo-triage/pack.yaml +0 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/examples/demo-triage/predictions-run2.jsonl +0 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/examples/demo-triage/predictions-shuffled.jsonl +0 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/examples/demo-triage/predictions.jsonl +0 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/src/jevassert/__main__.py +0 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/src/jevassert/client.py +0 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/src/jevassert/compare.py +0 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/src/jevassert/packs.py +0 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/src/jevassert/runner.py +0 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/tests/conftest.py +0 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/tests/helpers.py +0 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/tests/test_cli.py +0 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/tests/test_metrics.py +0 -0
- {jevassert-0.1.0 → jevassert-0.2.0}/tests/test_packs.py +0 -0
- {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
|
|
20
|
-
Definitions are in the module
|
|
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
|
|
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.
|
|
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
|
[](https://github.com/dtduc-git/jevassert/actions/workflows/ci.yml)
|
|
33
|
+
[](https://pypi.org/project/jevassert/)
|
|
30
34
|
[](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
|
|
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
|
[](https://github.com/dtduc-git/jevassert/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/jevassert/)
|
|
4
5
|
[](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
|
|
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[@]}"
|
|
@@ -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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
428
|
-
|
|
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
|
|
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
|
|
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)
|
|
258
|
-
|
|
259
|
-
|
|
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 = (
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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)"
|