jevcheck 0.2.0__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.
- jevcheck/__init__.py +57 -0
- jevcheck/__main__.py +3 -0
- jevcheck/answers.py +228 -0
- jevcheck/cli.py +356 -0
- jevcheck/client.py +78 -0
- jevcheck/compare.py +199 -0
- jevcheck/contract.py +314 -0
- jevcheck/eval.py +450 -0
- jevcheck/gate.py +61 -0
- jevcheck/pinning.py +101 -0
- jevcheck/py.typed +0 -0
- jevcheck/questions.py +100 -0
- jevcheck-0.2.0.dist-info/METADATA +162 -0
- jevcheck-0.2.0.dist-info/RECORD +18 -0
- jevcheck-0.2.0.dist-info/WHEEL +5 -0
- jevcheck-0.2.0.dist-info/entry_points.txt +2 -0
- jevcheck-0.2.0.dist-info/licenses/LICENSE +21 -0
- jevcheck-0.2.0.dist-info/top_level.txt +1 -0
jevcheck/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""jevcheck — pytest for Jev behavior."""
|
|
2
|
+
|
|
3
|
+
from jevcheck.answers import (
|
|
4
|
+
ChoiceAnswer,
|
|
5
|
+
JevResponse,
|
|
6
|
+
NoulAnswer,
|
|
7
|
+
ScoreAnswer,
|
|
8
|
+
Usage,
|
|
9
|
+
mapped_confidence,
|
|
10
|
+
)
|
|
11
|
+
from jevcheck.client import AUTH_ENV, JevClient
|
|
12
|
+
from jevcheck.compare import compare, record_answers
|
|
13
|
+
from jevcheck.contract import Case, Contract, FieldExpect, load_contract, load_replay, write_replay
|
|
14
|
+
from jevcheck.eval import CaseResult, EvalReport, Outcome, evaluate, evaluate_case
|
|
15
|
+
from jevcheck.gate import Action, Decision, Gate
|
|
16
|
+
from jevcheck.pinning import (
|
|
17
|
+
ModelIdentityError,
|
|
18
|
+
UnpinnedModelError,
|
|
19
|
+
is_unpinned,
|
|
20
|
+
require_pinned,
|
|
21
|
+
require_response_identity,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__version__ = "0.2.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AUTH_ENV",
|
|
28
|
+
"Action",
|
|
29
|
+
"Case",
|
|
30
|
+
"CaseResult",
|
|
31
|
+
"ChoiceAnswer",
|
|
32
|
+
"Contract",
|
|
33
|
+
"Decision",
|
|
34
|
+
"EvalReport",
|
|
35
|
+
"compare",
|
|
36
|
+
"FieldExpect",
|
|
37
|
+
"Gate",
|
|
38
|
+
"JevClient",
|
|
39
|
+
"JevResponse",
|
|
40
|
+
"ModelIdentityError",
|
|
41
|
+
"NoulAnswer",
|
|
42
|
+
"Outcome",
|
|
43
|
+
"ScoreAnswer",
|
|
44
|
+
"UnpinnedModelError",
|
|
45
|
+
"Usage",
|
|
46
|
+
"evaluate",
|
|
47
|
+
"evaluate_case",
|
|
48
|
+
"is_unpinned",
|
|
49
|
+
"load_contract",
|
|
50
|
+
"load_replay",
|
|
51
|
+
"record_answers",
|
|
52
|
+
"write_replay",
|
|
53
|
+
"mapped_confidence",
|
|
54
|
+
"require_pinned",
|
|
55
|
+
"require_response_identity",
|
|
56
|
+
"__version__",
|
|
57
|
+
]
|
jevcheck/__main__.py
ADDED
jevcheck/answers.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Verified System One answer models. Fields match docs/jev-api.md only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Annotated, Any, Literal, Mapping
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr, field_validator
|
|
9
|
+
|
|
10
|
+
from jevcheck.questions import JSONContent
|
|
11
|
+
|
|
12
|
+
# Probability maps must be finite, nonnegative, and sum to 1 within this
|
|
13
|
+
# absolute tolerance. JSON/IEEE floats will not hit 1.0 exactly for many
|
|
14
|
+
# hand-written distributions; 1e-6 is the agreed check, not a fixture-format
|
|
15
|
+
# change. Maps that miss this are rejected rather than silently renormalized.
|
|
16
|
+
PROBABILITY_SUM_TOLERANCE = 1e-6
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Usage(BaseModel):
|
|
20
|
+
model_config = ConfigDict(extra="ignore", frozen=True)
|
|
21
|
+
|
|
22
|
+
input_tokens: StrictInt | None = None
|
|
23
|
+
output_tokens: StrictInt | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ChoiceAnswer(BaseModel):
|
|
27
|
+
model_config = ConfigDict(extra="ignore", frozen=True)
|
|
28
|
+
|
|
29
|
+
type: Literal["choice"] = "choice"
|
|
30
|
+
choice: StrictStr
|
|
31
|
+
confidence: float = Field(ge=0.0, le=1.0)
|
|
32
|
+
probabilities: dict[str, float]
|
|
33
|
+
|
|
34
|
+
@field_validator("confidence")
|
|
35
|
+
@classmethod
|
|
36
|
+
def _finite_confidence(cls, value: float) -> float:
|
|
37
|
+
return _require_finite(value, "choice confidence")
|
|
38
|
+
|
|
39
|
+
@field_validator("probabilities")
|
|
40
|
+
@classmethod
|
|
41
|
+
def _valid_probabilities(cls, value: dict[str, float]) -> dict[str, float]:
|
|
42
|
+
return validate_probability_map(value, what="choice")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NoulAnswer(BaseModel):
|
|
46
|
+
model_config = ConfigDict(extra="ignore", frozen=True)
|
|
47
|
+
|
|
48
|
+
type: Literal["noul"] = "noul"
|
|
49
|
+
noul: float = Field(ge=0.0, le=1.0)
|
|
50
|
+
|
|
51
|
+
@field_validator("noul")
|
|
52
|
+
@classmethod
|
|
53
|
+
def _finite_noul(cls, value: float) -> float:
|
|
54
|
+
return _require_finite(value, "noul")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ScoreAnswer(BaseModel):
|
|
58
|
+
model_config = ConfigDict(extra="ignore", frozen=True)
|
|
59
|
+
|
|
60
|
+
type: Literal["score"] = "score"
|
|
61
|
+
score: float
|
|
62
|
+
confidence: float = Field(ge=0.0, le=1.0)
|
|
63
|
+
legend: dict[str, JSONContent]
|
|
64
|
+
probabilities: dict[str, float]
|
|
65
|
+
|
|
66
|
+
@field_validator("score", "confidence")
|
|
67
|
+
@classmethod
|
|
68
|
+
def _finite_score_fields(cls, value: float) -> float:
|
|
69
|
+
return _require_finite(value, "score field")
|
|
70
|
+
|
|
71
|
+
@field_validator("legend", "probabilities", mode="before")
|
|
72
|
+
@classmethod
|
|
73
|
+
def _stringify_int_keys(cls, value: Any) -> Any:
|
|
74
|
+
"""SDK 0.7.0 score maps use integer keys; coerce to str without inventing fields."""
|
|
75
|
+
if isinstance(value, Mapping):
|
|
76
|
+
return {str(key): item for key, item in value.items()}
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
@field_validator("legend")
|
|
80
|
+
@classmethod
|
|
81
|
+
def _valid_legend(cls, value: dict[str, JSONContent]) -> dict[str, JSONContent]:
|
|
82
|
+
if not value:
|
|
83
|
+
raise ValueError("score legend must be a nonempty mapping")
|
|
84
|
+
return value
|
|
85
|
+
|
|
86
|
+
@field_validator("probabilities")
|
|
87
|
+
@classmethod
|
|
88
|
+
def _valid_probabilities(cls, value: dict[str, float]) -> dict[str, float]:
|
|
89
|
+
return validate_probability_map(value, what="score")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
Answer = Annotated[
|
|
93
|
+
NoulAnswer | ChoiceAnswer | ScoreAnswer,
|
|
94
|
+
Field(discriminator="type"),
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class JevResponse(BaseModel):
|
|
99
|
+
model_config = ConfigDict(extra="ignore", frozen=True)
|
|
100
|
+
|
|
101
|
+
model: StrictStr
|
|
102
|
+
usage: Usage = Field(default_factory=Usage)
|
|
103
|
+
answers: dict[str, Answer] = Field(default_factory=dict)
|
|
104
|
+
|
|
105
|
+
@field_validator("model")
|
|
106
|
+
@classmethod
|
|
107
|
+
def _nonempty_model(cls, value: str) -> str:
|
|
108
|
+
if not value.strip():
|
|
109
|
+
raise ValueError("response.model must be a nonempty string")
|
|
110
|
+
return value
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def mapped_confidence(answer: NoulAnswer | ChoiceAnswer | ScoreAnswer) -> float:
|
|
114
|
+
"""Scalar used for floors, regressions, and the optional Gate helper."""
|
|
115
|
+
if isinstance(answer, NoulAnswer):
|
|
116
|
+
return answer.noul
|
|
117
|
+
return answer.confidence
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def adapt_answer(raw: Any) -> NoulAnswer | ChoiceAnswer | ScoreAnswer:
|
|
121
|
+
"""Accept SDK models, namespaces, or verified dicts.
|
|
122
|
+
|
|
123
|
+
Missing choice or score probabilities are rejected (not replaced with ``{}``).
|
|
124
|
+
Score ``legend`` is required and nonempty, matching SDK-shaped responses.
|
|
125
|
+
Score ``legend`` / ``probabilities`` integer keys from typesafe-sdk 0.7.0
|
|
126
|
+
are stringified so they match this schema.
|
|
127
|
+
"""
|
|
128
|
+
if isinstance(raw, (NoulAnswer, ChoiceAnswer, ScoreAnswer)):
|
|
129
|
+
return raw
|
|
130
|
+
data = _to_mapping(raw)
|
|
131
|
+
kind = data.get("type")
|
|
132
|
+
if kind == "noul" or ("noul" in data and "choice" not in data and "score" not in data):
|
|
133
|
+
return NoulAnswer.model_validate({"type": "noul", "noul": data["noul"]})
|
|
134
|
+
if kind == "choice" or "choice" in data:
|
|
135
|
+
payload: dict[str, Any] = {
|
|
136
|
+
"type": "choice",
|
|
137
|
+
"choice": data["choice"],
|
|
138
|
+
"confidence": data["confidence"],
|
|
139
|
+
}
|
|
140
|
+
if "probabilities" in data:
|
|
141
|
+
payload["probabilities"] = data["probabilities"]
|
|
142
|
+
return ChoiceAnswer.model_validate(payload)
|
|
143
|
+
if kind == "score" or "score" in data:
|
|
144
|
+
payload = {
|
|
145
|
+
"type": "score",
|
|
146
|
+
"score": data["score"],
|
|
147
|
+
"confidence": data["confidence"],
|
|
148
|
+
}
|
|
149
|
+
if "legend" in data:
|
|
150
|
+
payload["legend"] = data["legend"]
|
|
151
|
+
if "probabilities" in data:
|
|
152
|
+
payload["probabilities"] = data["probabilities"]
|
|
153
|
+
return ScoreAnswer.model_validate(payload)
|
|
154
|
+
raise ValueError(f"unsupported answer object: {raw!r}")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def adapt_response(raw: Any) -> JevResponse:
|
|
158
|
+
if isinstance(raw, JevResponse):
|
|
159
|
+
return raw
|
|
160
|
+
data = _to_mapping(raw)
|
|
161
|
+
model = data.get("model", _MISSING)
|
|
162
|
+
if model is _MISSING:
|
|
163
|
+
raise ValueError("response.model is required")
|
|
164
|
+
if model is None:
|
|
165
|
+
raise ValueError("response.model must not be null")
|
|
166
|
+
if not isinstance(model, str):
|
|
167
|
+
raise ValueError(f"response.model must be a string, not {type(model).__name__}")
|
|
168
|
+
answers_raw = data.get("answers") or {}
|
|
169
|
+
if not isinstance(answers_raw, Mapping):
|
|
170
|
+
raise TypeError("response.answers must be a mapping")
|
|
171
|
+
usage_raw = data.get("usage") or {}
|
|
172
|
+
return JevResponse(
|
|
173
|
+
model=model,
|
|
174
|
+
usage=Usage.model_validate(_to_mapping(usage_raw) if usage_raw is not None else {}),
|
|
175
|
+
answers={str(name): adapt_answer(answer) for name, answer in answers_raw.items()},
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def validate_probability_map(value: Any, *, what: str) -> dict[str, float]:
|
|
180
|
+
if not isinstance(value, Mapping) or not value:
|
|
181
|
+
raise ValueError(f"{what} probabilities must be a nonempty mapping")
|
|
182
|
+
cleaned: dict[str, float] = {}
|
|
183
|
+
for key, raw in value.items():
|
|
184
|
+
name = str(key)
|
|
185
|
+
try:
|
|
186
|
+
number = float(raw)
|
|
187
|
+
except (TypeError, ValueError) as exc:
|
|
188
|
+
raise ValueError(f"{what} probability {name!r} must be a finite number") from exc
|
|
189
|
+
if not math.isfinite(number):
|
|
190
|
+
raise ValueError(f"{what} probability {name!r} must be finite")
|
|
191
|
+
if number < 0.0:
|
|
192
|
+
raise ValueError(f"{what} probability {name!r} must be >= 0")
|
|
193
|
+
cleaned[name] = number
|
|
194
|
+
total = math.fsum(cleaned.values())
|
|
195
|
+
if abs(total - 1.0) > PROBABILITY_SUM_TOLERANCE:
|
|
196
|
+
raise ValueError(
|
|
197
|
+
f"{what} probabilities must sum to 1 ± {PROBABILITY_SUM_TOLERANCE}; got {total}"
|
|
198
|
+
)
|
|
199
|
+
return cleaned
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _require_finite(value: float, what: str) -> float:
|
|
203
|
+
if not math.isfinite(value):
|
|
204
|
+
raise ValueError(f"{what} must be a finite number")
|
|
205
|
+
return value
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _to_mapping(raw: Any) -> dict[str, Any]:
|
|
209
|
+
if isinstance(raw, Mapping):
|
|
210
|
+
return dict(raw)
|
|
211
|
+
if hasattr(raw, "model_dump"):
|
|
212
|
+
dumped = raw.model_dump()
|
|
213
|
+
if isinstance(dumped, Mapping):
|
|
214
|
+
return dict(dumped)
|
|
215
|
+
skip = {"model_config", "model_fields", "model_computed_fields"}
|
|
216
|
+
if hasattr(raw, "__dict__"):
|
|
217
|
+
return {k: v for k, v in vars(raw).items() if not k.startswith("_") and k not in skip}
|
|
218
|
+
public = {
|
|
219
|
+
name: getattr(raw, name)
|
|
220
|
+
for name in dir(raw)
|
|
221
|
+
if not name.startswith("_") and not callable(getattr(raw, name, None))
|
|
222
|
+
}
|
|
223
|
+
if public:
|
|
224
|
+
return public
|
|
225
|
+
raise TypeError(f"cannot adapt {type(raw).__name__} to a mapping")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
_MISSING = object()
|
jevcheck/cli.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""jevcheck CLI — eval a candidate model against a pinned contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from typesafe_sdk import (
|
|
13
|
+
TypeSafeAPIError,
|
|
14
|
+
TypeSafeAPIResponseValidationError,
|
|
15
|
+
TypeSafeAuthenticationError,
|
|
16
|
+
TypeSafeError,
|
|
17
|
+
TypeSafeInternalServerError,
|
|
18
|
+
TypeSafeRateLimitError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from jevcheck.answers import JevResponse
|
|
22
|
+
from jevcheck.client import AUTH_ENV, JevClient
|
|
23
|
+
from jevcheck.compare import compare, record_answers
|
|
24
|
+
from jevcheck.contract import Case, Contract, load_contract, load_replay, write_replay
|
|
25
|
+
from jevcheck.eval import Fetch, evaluate
|
|
26
|
+
from jevcheck.pinning import (
|
|
27
|
+
ModelIdentityError,
|
|
28
|
+
UnpinnedModelError,
|
|
29
|
+
format_resolved_model,
|
|
30
|
+
require_pinned,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Exit 0: compatible. Exit 1: behavioral contract failure.
|
|
34
|
+
# Exit 2: usage / input / identity. Exit 3: operational (HTTP / invalid API body).
|
|
35
|
+
EXIT_OK = 0
|
|
36
|
+
EXIT_BREAKING = 1
|
|
37
|
+
EXIT_USAGE = 2
|
|
38
|
+
EXIT_OPS = 3
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
42
|
+
parser = argparse.ArgumentParser(
|
|
43
|
+
prog="jevcheck",
|
|
44
|
+
description="Pin Jev production contracts and eval candidate model upgrades.",
|
|
45
|
+
)
|
|
46
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
47
|
+
|
|
48
|
+
eval_cmd = sub.add_parser("eval", help="compare a candidate model to a contract")
|
|
49
|
+
eval_cmd.add_argument("contract", type=Path, help="JSON contract or JSONL fixtures")
|
|
50
|
+
eval_cmd.add_argument(
|
|
51
|
+
"--candidate-model",
|
|
52
|
+
required=True,
|
|
53
|
+
help="explicit candidate model (upgrades must be intentional)",
|
|
54
|
+
)
|
|
55
|
+
eval_cmd.add_argument(
|
|
56
|
+
"--baseline-model",
|
|
57
|
+
default=None,
|
|
58
|
+
help="override contract baseline_model (required for JSONL without _meta)",
|
|
59
|
+
)
|
|
60
|
+
eval_cmd.add_argument(
|
|
61
|
+
"--answers",
|
|
62
|
+
type=Path,
|
|
63
|
+
default=None,
|
|
64
|
+
help="replay file of mocked System One responses keyed by case id",
|
|
65
|
+
)
|
|
66
|
+
eval_cmd.add_argument(
|
|
67
|
+
"--allow-unpinned",
|
|
68
|
+
action="store_true",
|
|
69
|
+
help=(
|
|
70
|
+
"opt in to floating names such as jev-latest and jev-preview; "
|
|
71
|
+
"accept a concrete resolved response model (not another alias)"
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
record_cmd = sub.add_parser(
|
|
76
|
+
"record",
|
|
77
|
+
help="fetch baseline answers and write a replay JSON (v0.2)",
|
|
78
|
+
)
|
|
79
|
+
record_cmd.add_argument("contract", type=Path, help="JSON contract or JSONL fixtures")
|
|
80
|
+
record_cmd.add_argument(
|
|
81
|
+
"--out",
|
|
82
|
+
type=Path,
|
|
83
|
+
required=True,
|
|
84
|
+
help="path to write a replay file usable by eval --answers / compare --from",
|
|
85
|
+
)
|
|
86
|
+
record_cmd.add_argument(
|
|
87
|
+
"--baseline-model",
|
|
88
|
+
default=None,
|
|
89
|
+
help="override contract baseline_model (required for JSONL without _meta)",
|
|
90
|
+
)
|
|
91
|
+
record_cmd.add_argument(
|
|
92
|
+
"--answers",
|
|
93
|
+
type=Path,
|
|
94
|
+
default=None,
|
|
95
|
+
help="replay file of mocked baseline responses (CI / tests; no live key)",
|
|
96
|
+
)
|
|
97
|
+
record_cmd.add_argument(
|
|
98
|
+
"--allow-unpinned",
|
|
99
|
+
action="store_true",
|
|
100
|
+
help=(
|
|
101
|
+
"opt in to floating names such as jev-latest and jev-preview; "
|
|
102
|
+
"accept a concrete resolved response model (not another alias)"
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
compare_cmd = sub.add_parser(
|
|
107
|
+
"compare",
|
|
108
|
+
help="evaluate a candidate against recorded or live baseline answers (v0.2)",
|
|
109
|
+
)
|
|
110
|
+
compare_cmd.add_argument("contract", type=Path, help="JSON contract or JSONL fixtures")
|
|
111
|
+
source = compare_cmd.add_mutually_exclusive_group(required=True)
|
|
112
|
+
source.add_argument(
|
|
113
|
+
"--from",
|
|
114
|
+
dest="from_replay",
|
|
115
|
+
type=Path,
|
|
116
|
+
default=None,
|
|
117
|
+
help="recorded baseline replay JSON (output of jevcheck record)",
|
|
118
|
+
)
|
|
119
|
+
source.add_argument(
|
|
120
|
+
"--from-model",
|
|
121
|
+
dest="from_model",
|
|
122
|
+
default=None,
|
|
123
|
+
help="live baseline model (fetches both models when --answers is omitted)",
|
|
124
|
+
)
|
|
125
|
+
compare_cmd.add_argument(
|
|
126
|
+
"--to",
|
|
127
|
+
dest="to_model",
|
|
128
|
+
required=True,
|
|
129
|
+
help="explicit candidate model (same pinning rules as eval --candidate-model)",
|
|
130
|
+
)
|
|
131
|
+
compare_cmd.add_argument(
|
|
132
|
+
"--baseline-model",
|
|
133
|
+
default=None,
|
|
134
|
+
help="override contract baseline_model (required for JSONL without _meta)",
|
|
135
|
+
)
|
|
136
|
+
compare_cmd.add_argument(
|
|
137
|
+
"--answers",
|
|
138
|
+
type=Path,
|
|
139
|
+
default=None,
|
|
140
|
+
help="replay file of mocked candidate responses keyed by case id",
|
|
141
|
+
)
|
|
142
|
+
compare_cmd.add_argument(
|
|
143
|
+
"--allow-unpinned",
|
|
144
|
+
action="store_true",
|
|
145
|
+
help=(
|
|
146
|
+
"opt in to floating names such as jev-latest and jev-preview; "
|
|
147
|
+
"accept a concrete resolved response model (not another alias)"
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
args = parser.parse_args(list(argv) if argv is not None else None)
|
|
152
|
+
handlers = {
|
|
153
|
+
"eval": _run_eval,
|
|
154
|
+
"record": _run_record,
|
|
155
|
+
"compare": _run_compare,
|
|
156
|
+
}
|
|
157
|
+
if args.command not in handlers:
|
|
158
|
+
parser.error("unknown command")
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
return handlers[args.command](args)
|
|
162
|
+
except UnpinnedModelError as exc:
|
|
163
|
+
_print_error(exc)
|
|
164
|
+
return EXIT_USAGE
|
|
165
|
+
except ModelIdentityError as exc:
|
|
166
|
+
_print_error(exc)
|
|
167
|
+
return EXIT_USAGE
|
|
168
|
+
except TypeSafeAuthenticationError as exc:
|
|
169
|
+
_print_error(f"authentication failed ({exc.status}): {exc}")
|
|
170
|
+
return EXIT_OPS
|
|
171
|
+
except TypeSafeRateLimitError as exc:
|
|
172
|
+
_print_error(f"rate limited ({exc.status}): {exc}")
|
|
173
|
+
return EXIT_OPS
|
|
174
|
+
except TypeSafeInternalServerError as exc:
|
|
175
|
+
_print_error(f"server error ({exc.status}): {exc}")
|
|
176
|
+
return EXIT_OPS
|
|
177
|
+
except TypeSafeAPIResponseValidationError as exc:
|
|
178
|
+
_print_error(f"invalid API response: {exc}")
|
|
179
|
+
return EXIT_OPS
|
|
180
|
+
except TypeSafeAPIError as exc:
|
|
181
|
+
_print_error(f"API error ({exc.status}): {exc}")
|
|
182
|
+
return EXIT_OPS
|
|
183
|
+
except TypeSafeError as exc:
|
|
184
|
+
_print_error(exc)
|
|
185
|
+
return EXIT_USAGE
|
|
186
|
+
except (OSError, TypeError, ValueError, KeyError, RuntimeError) as exc:
|
|
187
|
+
_print_error(exc)
|
|
188
|
+
return EXIT_USAGE
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _run_eval(args: argparse.Namespace) -> int:
|
|
192
|
+
allow = bool(args.allow_unpinned)
|
|
193
|
+
candidate = require_pinned(args.candidate_model, allow_unpinned=allow)
|
|
194
|
+
contract: Contract = load_contract(
|
|
195
|
+
args.contract,
|
|
196
|
+
baseline_model=args.baseline_model,
|
|
197
|
+
allow_unpinned=allow,
|
|
198
|
+
)
|
|
199
|
+
fetch = _answers_or_live_fetch(
|
|
200
|
+
contract,
|
|
201
|
+
args.answers,
|
|
202
|
+
model=candidate,
|
|
203
|
+
allow=allow,
|
|
204
|
+
live_need="live eval",
|
|
205
|
+
)
|
|
206
|
+
report = evaluate(
|
|
207
|
+
contract,
|
|
208
|
+
fetch,
|
|
209
|
+
candidate_model=candidate,
|
|
210
|
+
allow_unpinned=allow,
|
|
211
|
+
)
|
|
212
|
+
sys.stdout.write(report.summary())
|
|
213
|
+
return EXIT_BREAKING if report.breaking else EXIT_OK
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _run_record(args: argparse.Namespace) -> int:
|
|
217
|
+
allow = bool(args.allow_unpinned)
|
|
218
|
+
contract = load_contract(
|
|
219
|
+
args.contract,
|
|
220
|
+
baseline_model=args.baseline_model,
|
|
221
|
+
allow_unpinned=allow,
|
|
222
|
+
)
|
|
223
|
+
baseline = require_pinned(contract.baseline_model, allow_unpinned=allow)
|
|
224
|
+
fetch = _answers_or_live_fetch(
|
|
225
|
+
contract,
|
|
226
|
+
args.answers,
|
|
227
|
+
model=baseline,
|
|
228
|
+
allow=allow,
|
|
229
|
+
live_need="live record",
|
|
230
|
+
)
|
|
231
|
+
recorded = record_answers(
|
|
232
|
+
contract,
|
|
233
|
+
fetch,
|
|
234
|
+
baseline_model=baseline,
|
|
235
|
+
allow_unpinned=allow,
|
|
236
|
+
)
|
|
237
|
+
write_replay(args.out, recorded)
|
|
238
|
+
title = contract.name or "contract"
|
|
239
|
+
resolved_models = list(dict.fromkeys(response.model for response in recorded.values()))
|
|
240
|
+
if len(resolved_models) == 1:
|
|
241
|
+
resolved = resolved_models[0]
|
|
242
|
+
elif resolved_models:
|
|
243
|
+
resolved = ", ".join(resolved_models)
|
|
244
|
+
else:
|
|
245
|
+
resolved = baseline
|
|
246
|
+
sys.stdout.write(
|
|
247
|
+
f"jevcheck record: {title}\n"
|
|
248
|
+
f"baseline: {format_resolved_model(baseline, resolved)}\n"
|
|
249
|
+
f"cases: {len(recorded)}\n"
|
|
250
|
+
f"wrote: {args.out}\n"
|
|
251
|
+
)
|
|
252
|
+
return EXIT_OK
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _run_compare(args: argparse.Namespace) -> int:
|
|
256
|
+
allow = bool(args.allow_unpinned)
|
|
257
|
+
if args.from_model and args.baseline_model:
|
|
258
|
+
from_pin = require_pinned(args.from_model, allow_unpinned=allow)
|
|
259
|
+
base_pin = require_pinned(args.baseline_model, allow_unpinned=allow)
|
|
260
|
+
if from_pin != base_pin:
|
|
261
|
+
raise ValueError("--from-model and --baseline-model disagree")
|
|
262
|
+
baseline_override = args.from_model or args.baseline_model
|
|
263
|
+
contract = load_contract(
|
|
264
|
+
args.contract,
|
|
265
|
+
baseline_model=baseline_override,
|
|
266
|
+
allow_unpinned=allow,
|
|
267
|
+
)
|
|
268
|
+
candidate = require_pinned(args.to_model, allow_unpinned=allow)
|
|
269
|
+
if args.from_replay is not None:
|
|
270
|
+
baseline = load_replay(args.from_replay)
|
|
271
|
+
else:
|
|
272
|
+
live_baseline = require_pinned(contract.baseline_model, allow_unpinned=allow)
|
|
273
|
+
baseline_fetch = _answers_or_live_fetch(
|
|
274
|
+
contract,
|
|
275
|
+
None,
|
|
276
|
+
model=live_baseline,
|
|
277
|
+
allow=allow,
|
|
278
|
+
live_need="live compare",
|
|
279
|
+
)
|
|
280
|
+
baseline = record_answers(
|
|
281
|
+
contract,
|
|
282
|
+
baseline_fetch,
|
|
283
|
+
baseline_model=live_baseline,
|
|
284
|
+
allow_unpinned=allow,
|
|
285
|
+
)
|
|
286
|
+
fetch = _answers_or_live_fetch(
|
|
287
|
+
contract,
|
|
288
|
+
args.answers,
|
|
289
|
+
model=candidate,
|
|
290
|
+
allow=allow,
|
|
291
|
+
live_need="live compare",
|
|
292
|
+
)
|
|
293
|
+
report = compare(
|
|
294
|
+
contract,
|
|
295
|
+
baseline,
|
|
296
|
+
fetch,
|
|
297
|
+
candidate_model=candidate,
|
|
298
|
+
allow_unpinned=allow,
|
|
299
|
+
)
|
|
300
|
+
sys.stdout.write(report.summary())
|
|
301
|
+
return EXIT_BREAKING if report.breaking else EXIT_OK
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _answers_or_live_fetch(
|
|
305
|
+
contract: Contract,
|
|
306
|
+
answers: Path | None,
|
|
307
|
+
*,
|
|
308
|
+
model: str,
|
|
309
|
+
allow: bool,
|
|
310
|
+
live_need: str,
|
|
311
|
+
) -> Fetch:
|
|
312
|
+
if answers is not None:
|
|
313
|
+
replay = load_replay(answers)
|
|
314
|
+
missing = [case.id for case in contract.cases if case.id not in replay]
|
|
315
|
+
if missing:
|
|
316
|
+
raise KeyError(f"replay file missing cases: {missing}")
|
|
317
|
+
|
|
318
|
+
def fetch(case: Case) -> JevResponse:
|
|
319
|
+
return replay[case.id]
|
|
320
|
+
|
|
321
|
+
return fetch
|
|
322
|
+
|
|
323
|
+
client = JevClient(model=model, allow_unpinned=allow)
|
|
324
|
+
|
|
325
|
+
def fetch(case: Case) -> JevResponse:
|
|
326
|
+
return client.system_one(state=case.state, questions=case.questions, model=model)
|
|
327
|
+
|
|
328
|
+
if not client.api_key:
|
|
329
|
+
raise RuntimeError(f"{live_need} needs {AUTH_ENV} or a --answers replay file")
|
|
330
|
+
return fetch
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def redact_secrets(text: str, *secrets: str | None) -> str:
|
|
334
|
+
"""Remove known credentials from user-visible error text."""
|
|
335
|
+
redacted = text
|
|
336
|
+
for secret in secrets:
|
|
337
|
+
if secret:
|
|
338
|
+
redacted = redacted.replace(secret, "***")
|
|
339
|
+
env_key = os.environ.get(AUTH_ENV)
|
|
340
|
+
if env_key:
|
|
341
|
+
redacted = redacted.replace(env_key, "***")
|
|
342
|
+
redacted = re.sub(r"(?i)(bearer\s+)\S+", r"\1***", redacted)
|
|
343
|
+
redacted = re.sub(
|
|
344
|
+
r"(?i)(authorization\s*[:=]\s*)(\S+)",
|
|
345
|
+
r"\1***",
|
|
346
|
+
redacted,
|
|
347
|
+
)
|
|
348
|
+
return redacted
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _print_error(exc: object) -> None:
|
|
352
|
+
print(f"jevcheck: {redact_secrets(str(exc))}", file=sys.stderr)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
if __name__ == "__main__":
|
|
356
|
+
raise SystemExit(main())
|