skillproof 0.1.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.
Files changed (43) hide show
  1. skillproof/__init__.py +3 -0
  2. skillproof/__main__.py +3 -0
  3. skillproof/assertions/__init__.py +62 -0
  4. skillproof/assertions/base.py +44 -0
  5. skillproof/assertions/builtin.py +176 -0
  6. skillproof/assertions/llm_judge.py +24 -0
  7. skillproof/cli.py +313 -0
  8. skillproof/config.py +188 -0
  9. skillproof/engine.py +268 -0
  10. skillproof/errors.py +72 -0
  11. skillproof/estimate.py +83 -0
  12. skillproof/judges/__init__.py +43 -0
  13. skillproof/judges/anthropic_judge.py +59 -0
  14. skillproof/judges/base.py +69 -0
  15. skillproof/judges/mock.py +21 -0
  16. skillproof/judges/openai_judge.py +58 -0
  17. skillproof/parsers/__init__.py +6 -0
  18. skillproof/parsers/claude_code.py +144 -0
  19. skillproof/parsers/codex.py +112 -0
  20. skillproof/report/__init__.py +34 -0
  21. skillproof/report/badge.py +60 -0
  22. skillproof/report/format.py +75 -0
  23. skillproof/report/html_report.py +296 -0
  24. skillproof/report/json_report.py +9 -0
  25. skillproof/report/markdown.py +78 -0
  26. skillproof/report/summary.py +216 -0
  27. skillproof/report/terminal.py +89 -0
  28. skillproof/results.py +88 -0
  29. skillproof/runners/__init__.py +33 -0
  30. skillproof/runners/base.py +102 -0
  31. skillproof/runners/claude_code.py +117 -0
  32. skillproof/runners/codex.py +133 -0
  33. skillproof/runners/mock.py +91 -0
  34. skillproof/runners/openai_compat.py +238 -0
  35. skillproof/sandbox.py +85 -0
  36. skillproof/scaffold.py +153 -0
  37. skillproof/skill.py +97 -0
  38. skillproof/stats.py +228 -0
  39. skillproof-0.1.0.dist-info/METADATA +262 -0
  40. skillproof-0.1.0.dist-info/RECORD +43 -0
  41. skillproof-0.1.0.dist-info/WHEEL +4 -0
  42. skillproof-0.1.0.dist-info/entry_points.txt +2 -0
  43. skillproof-0.1.0.dist-info/licenses/LICENSE +21 -0
skillproof/config.py ADDED
@@ -0,0 +1,188 @@
1
+ """The evals.yaml schema and loader.
2
+
3
+ Paths in evals.yaml are relative to the evals.yaml file. `load_config()`
4
+ returns a config with every path resolved to an absolute one.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Any, Literal
11
+
12
+ import yaml
13
+ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, ValidationError, model_validator
14
+
15
+ from skillproof.assertions import ASSERTION_TYPES, Assertion
16
+ from skillproof.errors import ConfigError
17
+ from skillproof.runners import RUNNERS, get_runner_class
18
+
19
+ CONFIG_NAMES = ("evals.yaml", "evals.yml")
20
+
21
+
22
+ class JudgeConfig(BaseModel):
23
+ """Which model grades `llm_judge` assertions.
24
+
25
+ `auto` picks a vendor different from the agent under test (Claude agents
26
+ are judged by an OpenAI model and vice versa) to avoid self-grading.
27
+ """
28
+
29
+ model_config = ConfigDict(extra="forbid")
30
+
31
+ provider: Literal["auto", "anthropic", "openai", "mock"] = "auto"
32
+ model: str | None = None # None = the provider's default judge model
33
+ base_url: str | None = None # for OpenAI-compatible judge endpoints
34
+ api_key_env: str | None = None # env var holding the key; None = provider default
35
+
36
+
37
+ class Case(BaseModel):
38
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
39
+
40
+ name: str = Field(min_length=1)
41
+ prompt: str = Field(min_length=1)
42
+ fixtures: Path | None = None # folder copied into each run's sandbox
43
+ trials: int | None = Field(default=None, ge=1, le=100) # overrides the top-level value
44
+ timeout: float | None = Field(default=None, gt=0) # seconds; overrides top-level
45
+ # false = a negative case: the skill should NOT activate for this prompt.
46
+ should_trigger: bool = True
47
+ assertions: list[Assertion] = Field(default_factory=list, alias="assert")
48
+
49
+
50
+ class EvalConfig(BaseModel):
51
+ model_config = ConfigDict(extra="forbid")
52
+
53
+ skill: Path = Path(".")
54
+ runner: str = "claude-code"
55
+ model: str | None = None # passed to the runner; None = the agent's default
56
+ runner_options: dict[str, Any] = Field(default_factory=dict)
57
+ trials: int = Field(default=5, ge=1, le=100)
58
+ timeout: float = Field(default=300, gt=0) # seconds per run
59
+ judge: JudgeConfig = Field(default_factory=JudgeConfig)
60
+ cases: list[Case] = Field(min_length=1)
61
+
62
+ _path: Path | None = PrivateAttr(default=None)
63
+
64
+ @model_validator(mode="after")
65
+ def _check(self) -> EvalConfig:
66
+ if self.runner not in RUNNERS:
67
+ valid = ", ".join(RUNNERS)
68
+ raise ValueError(f"unknown runner {self.runner!r}. Valid runners: {valid}")
69
+ if self.model is None and get_runner_class(self.runner).requires_model:
70
+ raise ValueError(f"the {self.runner} runner needs `model:` (e.g. model: gpt-6.1-sol)")
71
+ seen: set[str] = set()
72
+ for case in self.cases:
73
+ if case.name in seen:
74
+ raise ValueError(f"duplicate case name {case.name!r}; case names must be unique")
75
+ seen.add(case.name)
76
+ return self
77
+
78
+ @property
79
+ def path(self) -> Path | None:
80
+ """The evals.yaml this config was loaded from."""
81
+ return self._path
82
+
83
+ def trials_for(self, case: Case) -> int:
84
+ return case.trials or self.trials
85
+
86
+ def timeout_for(self, case: Case) -> float:
87
+ return case.timeout or self.timeout
88
+
89
+ def uses_judge(self) -> bool:
90
+ return any(a.type == "llm_judge" for c in self.cases for a in c.assertions)
91
+
92
+
93
+ def find_config(path: Path) -> Path:
94
+ """Accept either an evals.yaml file or a folder containing one."""
95
+ if path.is_file():
96
+ return path
97
+ if path.is_dir():
98
+ for name in CONFIG_NAMES:
99
+ if (path / name).is_file():
100
+ return path / name
101
+ raise ConfigError(
102
+ f"No evals.yaml in {path}",
103
+ hint=f"Create one with: skillproof init {path}",
104
+ )
105
+ raise ConfigError(f"Path not found: {path}", hint="Pass a skill folder or an evals.yaml file.")
106
+
107
+
108
+ def format_validation_error(exc: ValidationError, source: Path | str) -> str:
109
+ """Turn pydantic errors into lines like `cases[0].assert[1]: unknown field 'vale'`."""
110
+ lines = []
111
+ for err in exc.errors():
112
+ loc = ""
113
+ for part in err["loc"]:
114
+ if isinstance(part, int):
115
+ loc += f"[{part}]"
116
+ elif part in ASSERTION_TYPES:
117
+ continue # pydantic adds the union tag to the path; it's noise for users
118
+ else:
119
+ loc += f".{part}" if loc else str(part)
120
+ msg = err["msg"]
121
+ if err["type"] == "extra_forbidden":
122
+ msg = f"unknown field {err['loc'][-1]!r} (typo?)"
123
+ elif err["type"] == "missing":
124
+ msg = "missing required field"
125
+ elif err["type"] == "union_tag_invalid":
126
+ msg = f"unknown assertion type {err['input'].get('type')!r}. " + (
127
+ "Valid types: " + ", ".join(ASSERTION_TYPES)
128
+ )
129
+ elif msg.startswith("Value error, "):
130
+ msg = msg.removeprefix("Value error, ")
131
+ lines.append(f" {loc or '(top level)'}: {msg}")
132
+ return f"{source} is invalid:\n" + "\n".join(lines)
133
+
134
+
135
+ def parse_config(data: Any, base_dir: Path, source: Path | str = "evals.yaml") -> EvalConfig:
136
+ """Validate raw YAML data and resolve paths relative to `base_dir`."""
137
+ if not isinstance(data, dict):
138
+ raise ConfigError(
139
+ f"{source} must be a YAML mapping (key: value pairs).",
140
+ hint="Run `skillproof init` to see an example evals.yaml.",
141
+ )
142
+ try:
143
+ config = EvalConfig.model_validate(data)
144
+ except ValidationError as exc:
145
+ raise ConfigError(
146
+ format_validation_error(exc, source),
147
+ hint="Fix the fields above. See `skillproof init` output for a working example.",
148
+ ) from exc
149
+
150
+ base_dir = base_dir.resolve()
151
+ config.skill = (base_dir / config.skill).resolve()
152
+ for case in config.cases:
153
+ if case.fixtures is None:
154
+ continue
155
+ case.fixtures = (base_dir / case.fixtures).resolve()
156
+ if not case.fixtures.is_dir():
157
+ raise ConfigError(
158
+ f"Case {case.name!r}: fixtures folder not found: {case.fixtures}",
159
+ hint="`fixtures:` is a folder, relative to evals.yaml, copied into each sandbox.",
160
+ )
161
+
162
+ _validate_runner_options(config, source)
163
+ return config
164
+
165
+
166
+ def _validate_runner_options(config: EvalConfig, source: Path | str) -> None:
167
+ options_model = get_runner_class(config.runner).Options
168
+ try:
169
+ options_model.model_validate(config.runner_options)
170
+ except ValidationError as exc:
171
+ message = format_validation_error(exc, f"{source} (runner_options for {config.runner})")
172
+ raise ConfigError(
173
+ message, hint=f"Check the `runner_options` supported by the {config.runner} runner."
174
+ ) from exc
175
+
176
+
177
+ def load_config(path: Path) -> EvalConfig:
178
+ config_path = find_config(path).resolve()
179
+ try:
180
+ data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
181
+ except yaml.YAMLError as exc:
182
+ raise ConfigError(
183
+ f"{config_path} is not valid YAML: {exc}",
184
+ hint="Check indentation and quoting near the line mentioned above.",
185
+ ) from exc
186
+ config = parse_config(data, config_path.parent, source=config_path.name)
187
+ config._path = config_path
188
+ return config
skillproof/engine.py ADDED
@@ -0,0 +1,268 @@
1
+ """Runs an eval suite: every case, N trials, with and without the skill.
2
+
3
+ Each run gets a fresh sandbox (fixtures copied in), the runner does its thing,
4
+ assertions are checked against the sandbox, and the transcript is saved under
5
+ `<out_dir>/runs/<timestamp>/` with secrets redacted.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import re
12
+ import shutil
13
+ import time
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+
19
+ from skillproof.assertions import CheckContext, check_all
20
+ from skillproof.config import Case, EvalConfig
21
+ from skillproof.errors import ConfigError, RunAborted, hint_for_error
22
+ from skillproof.judges import Judge, make_judge
23
+ from skillproof.judges.base import JudgeUnavailable
24
+ from skillproof.results import CaseResult, SuiteResult, TrialResult, Variant
25
+ from skillproof.runners import AgentRun, Runner, RunRequest, get_runner
26
+ from skillproof.sandbox import create_sandbox, redact, remove_sandbox
27
+ from skillproof.skill import Skill, load_skill
28
+
29
+ VARIANTS: tuple[Variant, ...] = ("with", "without")
30
+ # Stop early when this many runs have finished and every one of them errored.
31
+ FAIL_FAST_AFTER = 3
32
+
33
+
34
+ @dataclass
35
+ class RunOptions:
36
+ parallel: int = 4
37
+ out_dir: Path = Path(".skillproof")
38
+ keep_sandboxes: bool = False
39
+ trials: int | None = None # overrides evals.yaml for every case
40
+ only_cases: list[str] | None = None # run just these case names
41
+
42
+
43
+ @dataclass
44
+ class Job:
45
+ case: Case
46
+ variant: Variant
47
+ trial: int
48
+
49
+
50
+ def slugify(text: str) -> str:
51
+ return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:60] or "case"
52
+
53
+
54
+ def new_run_dir(out_dir: Path) -> Path:
55
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
56
+ run_dir = out_dir / "runs" / stamp
57
+ suffix = 1
58
+ while run_dir.exists():
59
+ suffix += 1
60
+ run_dir = out_dir / "runs" / f"{stamp}-{suffix}"
61
+ run_dir.mkdir(parents=True)
62
+ return run_dir
63
+
64
+
65
+ def selected_cases(config: EvalConfig, only: list[str] | None) -> list[Case]:
66
+ if not only:
67
+ return list(config.cases)
68
+ names = {c.name for c in config.cases}
69
+ unknown = [n for n in only if n not in names]
70
+ if unknown:
71
+ raise ConfigError(
72
+ f"No case named {unknown[0]!r}",
73
+ hint="Available cases: " + ", ".join(repr(n) for n in names),
74
+ )
75
+ return [c for c in config.cases if c.name in only]
76
+
77
+
78
+ class Engine:
79
+ def __init__(
80
+ self,
81
+ config: EvalConfig,
82
+ options: RunOptions | None = None,
83
+ runner: Runner | None = None,
84
+ judge: Judge | None = None,
85
+ on_trial: Callable[[TrialResult], None] | None = None,
86
+ ) -> None:
87
+ self.config = config
88
+ self.options = options or RunOptions()
89
+ self.runner = runner or get_runner(config.runner, config.runner_options)
90
+ self.judge = judge
91
+ self.on_trial = on_trial
92
+ self.cases = selected_cases(config, self.options.only_cases)
93
+
94
+ def jobs(self) -> list[Job]:
95
+ """Trials are interleaved (with/without, case by case) so drift over time
96
+ in API latency or model behaviour hits both variants equally."""
97
+ jobs = []
98
+ max_trials = max(self.trials_for(c) for c in self.cases)
99
+ for trial in range(max_trials):
100
+ for case in self.cases:
101
+ if trial < self.trials_for(case):
102
+ jobs.extend(Job(case, variant, trial) for variant in VARIANTS)
103
+ return jobs
104
+
105
+ def trials_for(self, case: Case) -> int:
106
+ return self.options.trials or self.config.trials_for(case)
107
+
108
+ def skill_exclude(self, skill: Skill) -> list[Path]:
109
+ """Eval files inside the skill folder that the agent must never see."""
110
+ paths = [c.fixtures for c in self.config.cases if c.fixtures is not None]
111
+ if self.config.path is not None:
112
+ paths.append(self.config.path)
113
+ return [p for p in paths if p.is_relative_to(skill.path)]
114
+
115
+ async def run(self) -> SuiteResult:
116
+ skill = load_skill(self.config.skill)
117
+ self.runner.check_ready()
118
+ if self.judge is None and self.config.uses_judge():
119
+ self.judge = make_judge(self.config.judge, self.runner.vendor)
120
+
121
+ run_dir = new_run_dir(self.options.out_dir)
122
+ suite = SuiteResult(
123
+ skill_name=skill.name,
124
+ skill_path=str(skill.path),
125
+ runner=self.runner.name,
126
+ approximate=self.runner.approximate,
127
+ model=self.config.model,
128
+ judge=self.judge.label if self.judge else None,
129
+ started_at=datetime.now(timezone.utc),
130
+ run_dir=str(run_dir),
131
+ )
132
+ exclude = self.skill_exclude(skill)
133
+ semaphore = asyncio.Semaphore(max(1, self.options.parallel))
134
+
135
+ trials: list[TrialResult] = []
136
+ stopped = False
137
+
138
+ async def limited(job: Job) -> None:
139
+ nonlocal stopped
140
+ async with semaphore:
141
+ if stopped: # don't start new runs once we've decided to stop
142
+ return
143
+ trials.append(await self.run_trial(job, skill, exclude, run_dir))
144
+ try:
145
+ self.stop_if_everything_fails(trials, run_dir)
146
+ except RunAborted:
147
+ stopped = True
148
+ raise
149
+
150
+ tasks = [asyncio.create_task(limited(job)) for job in self.jobs()]
151
+ try:
152
+ await asyncio.gather(*tasks)
153
+ except BaseException: # fail-fast, Ctrl-C (cancellation) or a crash
154
+ for task in tasks:
155
+ task.cancel()
156
+ await asyncio.gather(*tasks, return_exceptions=True)
157
+ self.finish(suite, trials, complete=False)
158
+ raise
159
+ self.finish(suite, trials, complete=True)
160
+ return suite
161
+
162
+ def stop_if_everything_fails(self, trials: list[TrialResult], run_dir: Path) -> None:
163
+ """If the first few runs all errored, the rest will too (bad key, wrong model...).
164
+ Stop instead of burning time and money, and say what to fix."""
165
+ if len(trials) != FAIL_FAST_AFTER or not all(t.errored for t in trials):
166
+ return
167
+ error = trials[-1].run.error or "unknown error"
168
+ raise RunAborted(
169
+ f"The first {FAIL_FAST_AFTER} runs all failed, so skillproof stopped early.\n"
170
+ f"Last error: {error.strip()[:500]}",
171
+ hint=f"{hint_for_error(error)} Transcripts: {run_dir}",
172
+ )
173
+
174
+ def finish(self, suite: SuiteResult, trials: list[TrialResult], complete: bool) -> None:
175
+ """Group trials into cases and save results.json (also for partial runs)."""
176
+ suite.cases = []
177
+ for case in self.cases:
178
+ case_trials = sorted(
179
+ (t for t in trials if t.case == case.name), key=lambda t: (t.variant, t.trial)
180
+ )
181
+ suite.cases.append(
182
+ CaseResult(
183
+ name=case.name,
184
+ should_trigger=case.should_trigger,
185
+ has_assertions=bool(case.assertions),
186
+ trials=case_trials,
187
+ )
188
+ )
189
+ suite.complete = complete
190
+ suite.finished_at = datetime.now(timezone.utc)
191
+ (Path(suite.run_dir) / "results.json").write_text(
192
+ redact(suite.model_dump_json(indent=2)), encoding="utf-8"
193
+ )
194
+
195
+ async def run_trial(
196
+ self, job: Job, skill: Skill, exclude: list[Path], run_dir: Path
197
+ ) -> TrialResult:
198
+ case = job.case
199
+ timeout = self.config.timeout_for(case)
200
+ workdir = create_sandbox(case.fixtures)
201
+ try:
202
+ request = RunRequest(
203
+ prompt=case.prompt,
204
+ workdir=workdir,
205
+ skill=skill if job.variant == "with" else None,
206
+ skill_exclude=exclude,
207
+ model=self.config.model,
208
+ timeout_s=timeout,
209
+ case_name=case.name,
210
+ trial=job.trial,
211
+ )
212
+ agent_run = await self.call_runner(request, timeout)
213
+ assertions = []
214
+ if agent_run.error is None: # don't grade (or pay a judge for) a broken run
215
+ ctx = CheckContext(run=agent_run, workdir=workdir, judge=self.judge)
216
+ try:
217
+ assertions = await check_all(case.assertions, ctx)
218
+ except JudgeUnavailable as exc:
219
+ agent_run.error = f"llm_judge failed: {exc}"
220
+ result = TrialResult(
221
+ case=case.name,
222
+ variant=job.variant,
223
+ trial=job.trial,
224
+ run=agent_run,
225
+ assertions=assertions,
226
+ )
227
+ self.save_trial(result, workdir, run_dir)
228
+ finally:
229
+ remove_sandbox(workdir)
230
+ if self.on_trial is not None:
231
+ self.on_trial(result)
232
+ return result
233
+
234
+ async def call_runner(self, request: RunRequest, timeout: float) -> AgentRun:
235
+ """Call the runner, enforcing the timeout and turning crashes into errors."""
236
+ start = time.monotonic()
237
+ try:
238
+ agent_run = await asyncio.wait_for(self.runner.run(request), timeout=timeout)
239
+ except asyncio.TimeoutError:
240
+ agent_run = AgentRun(timed_out=True)
241
+ except Exception as exc:
242
+ agent_run = AgentRun(error=f"{type(exc).__name__}: {exc}")
243
+ if not agent_run.duration_s:
244
+ agent_run.duration_s = time.monotonic() - start
245
+ return agent_run
246
+
247
+ def save_trial(self, result: TrialResult, workdir: Path, run_dir: Path) -> None:
248
+ case_dir = run_dir / slugify(result.case)
249
+ case_dir.mkdir(parents=True, exist_ok=True)
250
+ name = f"{result.variant}-{result.trial + 1}"
251
+ result.artifact = f"{case_dir.name}/{name}.json"
252
+ (case_dir / f"{name}.json").write_text(
253
+ redact(result.model_dump_json(indent=2)), encoding="utf-8"
254
+ )
255
+ if self.options.keep_sandboxes:
256
+ shutil.copytree(workdir, case_dir / f"{name}.workdir", symlinks=True)
257
+
258
+
259
+ def run_suite(
260
+ config: EvalConfig,
261
+ options: RunOptions | None = None,
262
+ runner: Runner | None = None,
263
+ judge: Judge | None = None,
264
+ on_trial: Callable[[TrialResult], None] | None = None,
265
+ ) -> SuiteResult:
266
+ """Synchronous entry point used by the CLI and tests."""
267
+ engine = Engine(config, options, runner=runner, judge=judge, on_trial=on_trial)
268
+ return asyncio.run(engine.run())
skillproof/errors.py ADDED
@@ -0,0 +1,72 @@
1
+ """Errors that are shown to the user.
2
+
3
+ Every error carries a `hint`: one sentence telling the user exactly what to do
4
+ next. The CLI prints the message and the hint and exits non-zero, without a
5
+ traceback.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class SkillproofError(Exception):
12
+ def __init__(self, message: str, hint: str | None = None) -> None:
13
+ super().__init__(message)
14
+ self.message = message
15
+ self.hint = hint
16
+
17
+
18
+ class ConfigError(SkillproofError):
19
+ """evals.yaml is missing, malformed, or points at things that don't exist."""
20
+
21
+
22
+ class SkillError(SkillproofError):
23
+ """The skill folder or its SKILL.md is missing or malformed."""
24
+
25
+
26
+ class RunnerNotReady(SkillproofError):
27
+ """A runner can't start: missing CLI, missing package, missing API key..."""
28
+
29
+
30
+ class JudgeNotReady(SkillproofError):
31
+ """An llm_judge assertion is used but no judge can be built."""
32
+
33
+
34
+ class RunAborted(SkillproofError):
35
+ """The run stopped early: the first runs all failed, or the user hit Ctrl-C.
36
+ Partial results are saved before this is raised."""
37
+
38
+
39
+ # (pattern, what to do) for errors agents and APIs commonly report.
40
+ ERROR_HINTS = [
41
+ (
42
+ r"401|unauthori[sz]ed|authenticat|invalid.{0,20}api.?key|x-api-key|not logged in|"
43
+ r"credentials",
44
+ "Your API key or login is missing or invalid. Check ANTHROPIC_API_KEY, OPENAI_API_KEY "
45
+ "or CODEX_API_KEY (or log in with `claude` / `codex login`).",
46
+ ),
47
+ (
48
+ r"credit|billing|quota|insufficient",
49
+ "Your API account is out of credits or quota. Check billing for the provider.",
50
+ ),
51
+ (
52
+ r"429|rate.?limit|overloaded|529|too many requests",
53
+ "The API is rate-limiting or overloaded. Lower --parallel (e.g. -p 1) and try again.",
54
+ ),
55
+ (
56
+ r"model.{0,40}(not.?found|does not exist|invalid|unknown|not supported)|404",
57
+ "The model name looks wrong. Check `model:` in evals.yaml.",
58
+ ),
59
+ (
60
+ r"enotfound|connection|network|name resolution|getaddrinfo|ssl",
61
+ "Network problem reaching the API. Check your connection, proxy and base_url.",
62
+ ),
63
+ ]
64
+
65
+
66
+ def hint_for_error(text: str) -> str:
67
+ import re
68
+
69
+ for pattern, hint in ERROR_HINTS:
70
+ if re.search(pattern, text, re.IGNORECASE):
71
+ return hint
72
+ return "Open the saved transcripts for details."
skillproof/estimate.py ADDED
@@ -0,0 +1,83 @@
1
+ """Rough cost estimate before a run starts, so nobody burns $50 by accident.
2
+
3
+ Per-run cost comes from your last run of the same skill with the same runner
4
+ when there is one (agents like Claude Code report cost per run). Otherwise a
5
+ deliberately rough default per runner is used; tasks vary 10x in cost, so the
6
+ first estimate is a ballpark, and later ones learn from your real runs.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from skillproof.engine import Engine
15
+ from skillproof.results import RESULTS_FILE, SuiteResult
16
+
17
+ # USD per agent run when there's no history: ballpark for a small coding task
18
+ # (our example skills averaged ~$0.05 per Claude Code run on claude-opus-5-5).
19
+ DEFAULT_COST_PER_RUN = {
20
+ "claude-code": 0.10,
21
+ "codex": 0.15,
22
+ "openai-compatible": 0.02,
23
+ "mock": 0.0,
24
+ }
25
+ # Ask before spending more than this (override with --confirm-above).
26
+ CONFIRM_ABOVE_USD = 5.0
27
+
28
+
29
+ @dataclass
30
+ class Estimate:
31
+ runs: int
32
+ judge_calls: int
33
+ cost_per_run: float | None # None: unknown runner and no history
34
+ source: str # where cost_per_run came from, shown to the user
35
+
36
+ @property
37
+ def total_usd(self) -> float | None:
38
+ return None if self.cost_per_run is None else self.runs * self.cost_per_run
39
+
40
+ def describe(self) -> str:
41
+ text = f"{self.runs} agent runs"
42
+ if self.judge_calls:
43
+ text += f" + {self.judge_calls} judge calls"
44
+ if self.total_usd is None:
45
+ return text + " · cost unknown"
46
+ if self.total_usd == 0:
47
+ return text + " · no API cost"
48
+ return (
49
+ f"{text} · estimated ~${self.total_usd:.2f} "
50
+ f"(${self.cost_per_run:.3f}/run, {self.source})"
51
+ )
52
+
53
+
54
+ def past_cost_per_run(out_dir: Path, skill_path: Path, runner: str) -> float | None:
55
+ """Mean reported cost per run in the newest saved run of this skill and runner."""
56
+ for results in sorted((out_dir / "runs").glob(f"*/{RESULTS_FILE}"), reverse=True):
57
+ try:
58
+ suite = SuiteResult.model_validate_json(results.read_text(encoding="utf-8"))
59
+ except ValueError:
60
+ continue # unreadable or from an incompatible version
61
+ if Path(suite.skill_path) != skill_path or suite.runner != runner:
62
+ continue
63
+ costs = [
64
+ t.run.usage.cost_usd
65
+ for c in suite.cases
66
+ for t in c.trials
67
+ if t.run.usage.cost_usd is not None
68
+ ]
69
+ if costs:
70
+ return sum(costs) / len(costs)
71
+ return None
72
+
73
+
74
+ def estimate(engine: Engine, out_dir: Path) -> Estimate:
75
+ jobs = engine.jobs()
76
+ judge_calls = sum(sum(a.type == "llm_judge" for a in job.case.assertions) for job in jobs)
77
+ runner = engine.runner.name
78
+ past = past_cost_per_run(out_dir, engine.config.skill, runner)
79
+ if past is not None:
80
+ return Estimate(len(jobs), judge_calls, past, "from your last run")
81
+ default = DEFAULT_COST_PER_RUN.get(runner)
82
+ source = f"rough default for {runner}" if default else "no estimate for this runner"
83
+ return Estimate(len(jobs), judge_calls, default, source)
@@ -0,0 +1,43 @@
1
+ """Building the judge for `llm_judge` assertions.
2
+
3
+ By default the judge comes from a different vendor than the agent under test,
4
+ so a model never grades its own family's homework.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ from skillproof.errors import JudgeNotReady
12
+ from skillproof.judges.base import Judge, Verdict
13
+ from skillproof.judges.mock import MockJudge
14
+
15
+ if TYPE_CHECKING:
16
+ from skillproof.config import JudgeConfig
17
+
18
+ # agent vendor -> judge provider used when `judge.provider: auto`
19
+ CROSS_VENDOR = {"anthropic": "openai", "openai": "anthropic"}
20
+
21
+
22
+ def resolve_provider(config: JudgeConfig, agent_vendor: str) -> str:
23
+ if config.provider != "auto":
24
+ return config.provider
25
+ return CROSS_VENDOR.get(agent_vendor, "anthropic")
26
+
27
+
28
+ def make_judge(config: JudgeConfig, agent_vendor: str) -> Judge:
29
+ provider = resolve_provider(config, agent_vendor)
30
+ if provider == "mock":
31
+ return MockJudge()
32
+ if provider == "anthropic":
33
+ from skillproof.judges.anthropic_judge import AnthropicJudge
34
+
35
+ return AnthropicJudge(config.model, config.api_key_env, config.base_url)
36
+ if provider == "openai":
37
+ from skillproof.judges.openai_judge import OpenAIJudge
38
+
39
+ return OpenAIJudge(config.model, config.api_key_env, config.base_url)
40
+ raise JudgeNotReady(f"Unknown judge provider {provider!r}.", hint="Use anthropic or openai.")
41
+
42
+
43
+ __all__ = ["Judge", "MockJudge", "Verdict", "make_judge", "resolve_provider"]