slmeval-cli 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.
- slmeval/__init__.py +71 -0
- slmeval/__main__.py +14 -0
- slmeval/adapters/__init__.py +24 -0
- slmeval/adapters/instrument.py +135 -0
- slmeval/adapters/langgraph.py +237 -0
- slmeval/cli/__init__.py +15 -0
- slmeval/cli/commands/__init__.py +35 -0
- slmeval/cli/commands/baseline_cmd.py +201 -0
- slmeval/cli/commands/cache_cmd.py +249 -0
- slmeval/cli/commands/dataset_cmd.py +52 -0
- slmeval/cli/commands/doctor_cmd.py +68 -0
- slmeval/cli/commands/gate_cmd.py +602 -0
- slmeval/cli/commands/init_cmd.py +144 -0
- slmeval/cli/commands/judge_cmd.py +284 -0
- slmeval/cli/commands/record_cmd.py +154 -0
- slmeval/cli/commands/report_cmd.py +98 -0
- slmeval/cli/commands/run_cmd.py +247 -0
- slmeval/cli/commands/show_cmd.py +482 -0
- slmeval/cli/commands/stubs.py +57 -0
- slmeval/cli/commands/validate_cmd.py +296 -0
- slmeval/cli/loading.py +149 -0
- slmeval/cli/main.py +91 -0
- slmeval/cli/render.py +251 -0
- slmeval/cli/wrap.py +31 -0
- slmeval/config/__init__.py +47 -0
- slmeval/config/loader.py +363 -0
- slmeval/config/locate.py +97 -0
- slmeval/config/models.py +430 -0
- slmeval/config/templates.py +246 -0
- slmeval/core/__init__.py +78 -0
- slmeval/core/adapter.py +253 -0
- slmeval/core/context.py +418 -0
- slmeval/core/outcome.py +260 -0
- slmeval/core/router.py +319 -0
- slmeval/core/runner.py +1209 -0
- slmeval/core/trace.py +188 -0
- slmeval/core/verdict.py +127 -0
- slmeval/dataset/__init__.py +8 -0
- slmeval/dataset/loader.py +130 -0
- slmeval/dataset/models.py +108 -0
- slmeval/doctor/__init__.py +7 -0
- slmeval/doctor/checks.py +380 -0
- slmeval/errors.py +76 -0
- slmeval/eval/__init__.py +64 -0
- slmeval/eval/assertions.py +356 -0
- slmeval/eval/calibration.py +364 -0
- slmeval/eval/cost.py +291 -0
- slmeval/eval/pipeline.py +273 -0
- slmeval/eval/stats.py +376 -0
- slmeval/eval/tier1.py +510 -0
- slmeval/eval/tier2.py +582 -0
- slmeval/eval/tier3.py +415 -0
- slmeval/exit_codes.py +28 -0
- slmeval/gate.py +722 -0
- slmeval/providers/__init__.py +69 -0
- slmeval/providers/base.py +426 -0
- slmeval/providers/fake.py +760 -0
- slmeval/providers/jsonx.py +239 -0
- slmeval/providers/ladder.py +339 -0
- slmeval/providers/openai_compat.py +392 -0
- slmeval/providers/registry.py +185 -0
- slmeval/providers/retry.py +81 -0
- slmeval/providers/schema.py +136 -0
- slmeval/providers/tokens.py +150 -0
- slmeval/py.typed +0 -0
- slmeval/report/__init__.py +115 -0
- slmeval/report/attribution.py +197 -0
- slmeval/report/baseline.py +353 -0
- slmeval/report/drilldown.py +1395 -0
- slmeval/report/export.py +450 -0
- slmeval/report/model.py +562 -0
- slmeval/report/progress.py +257 -0
- slmeval/report/scorecard.py +367 -0
- slmeval/sandbox/__init__.py +145 -0
- slmeval/sandbox/diagnosis.py +690 -0
- slmeval/sandbox/envelope.py +486 -0
- slmeval/sandbox/faults.py +794 -0
- slmeval/sandbox/keys.py +458 -0
- slmeval/sandbox/normalizers.py +333 -0
- slmeval/sandbox/store.py +525 -0
- slmeval/sandbox/tool.py +685 -0
- slmeval/storage/__init__.py +73 -0
- slmeval/storage/blobs.py +308 -0
- slmeval/storage/codec.py +93 -0
- slmeval/storage/db.py +1334 -0
- slmeval/storage/ingest.py +346 -0
- slmeval/storage/migrations.py +358 -0
- slmeval/storage/models.py +554 -0
- slmeval/storage/provenance.py +201 -0
- slmeval_cli-0.1.0.dist-info/METADATA +484 -0
- slmeval_cli-0.1.0.dist-info/RECORD +94 -0
- slmeval_cli-0.1.0.dist-info/WHEEL +4 -0
- slmeval_cli-0.1.0.dist-info/entry_points.txt +2 -0
- slmeval_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""`slmeval validate` — check the config and dataset before spending GPU time.
|
|
2
|
+
|
|
3
|
+
Also home to the cross-checks that neither schema can do alone. The most
|
|
4
|
+
valuable one is the `target_nodes` spell-check: "I benchmarked for an hour and
|
|
5
|
+
got identical numbers" is always a candidate pointing at a node name that does
|
|
6
|
+
not exist.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import inspect
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Annotated, Any
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
|
|
20
|
+
from slmeval import exit_codes
|
|
21
|
+
from slmeval.cli.render import console, symbols
|
|
22
|
+
from slmeval.cli.wrap import handle_errors
|
|
23
|
+
from slmeval.config import (
|
|
24
|
+
LoadedConfig,
|
|
25
|
+
find_config,
|
|
26
|
+
load_config,
|
|
27
|
+
require_dataset,
|
|
28
|
+
resolve_entrypoint,
|
|
29
|
+
)
|
|
30
|
+
from slmeval.config.locate import locate
|
|
31
|
+
from slmeval.dataset import Dataset, load_dataset
|
|
32
|
+
from slmeval.errors import Problem
|
|
33
|
+
|
|
34
|
+
MIN_RUNS_FOR_CONSISTENCY = 3
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def cross_check(loaded: LoadedConfig, dataset: Dataset) -> list[Problem]:
|
|
38
|
+
"""Problems that need both the config and the dataset to see."""
|
|
39
|
+
cfg = loaded.config
|
|
40
|
+
text = loaded.text
|
|
41
|
+
warnings: list[Problem] = []
|
|
42
|
+
|
|
43
|
+
if not cfg.candidates:
|
|
44
|
+
warnings.append(
|
|
45
|
+
Problem(
|
|
46
|
+
path="candidates",
|
|
47
|
+
message="no candidates defined - there is nothing to compare the baseline against",
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Node names, spell-checked against the trajectories declared in the dataset.
|
|
52
|
+
known_nodes = {
|
|
53
|
+
node for case in dataset.cases for path in case.assertions.reference_trajectory
|
|
54
|
+
for node in path
|
|
55
|
+
}
|
|
56
|
+
if known_nodes:
|
|
57
|
+
for i, candidate in enumerate(cfg.candidates):
|
|
58
|
+
unknown = [n for n in candidate.target_nodes if n not in known_nodes]
|
|
59
|
+
if unknown:
|
|
60
|
+
warnings.append(
|
|
61
|
+
Problem(
|
|
62
|
+
path=f"candidates[{i}].target_nodes",
|
|
63
|
+
message=f"{', '.join(repr(n) for n in unknown)} "
|
|
64
|
+
f"appear in no reference_trajectory. If misspelled, this candidate "
|
|
65
|
+
f"changes nothing and will score identically to the baseline. "
|
|
66
|
+
f"Known nodes: {', '.join(sorted(known_nodes))}",
|
|
67
|
+
line=locate(text, ("candidates", i, "target_nodes")),
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
elif any(c.target_nodes for c in cfg.candidates):
|
|
71
|
+
warnings.append(
|
|
72
|
+
Problem(
|
|
73
|
+
path="assert.reference_trajectory",
|
|
74
|
+
message="no case declares a reference_trajectory, so target_nodes cannot be "
|
|
75
|
+
"checked for typos",
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if cfg.benchmark.runs_per_case < MIN_RUNS_FOR_CONSISTENCY:
|
|
80
|
+
warnings.append(
|
|
81
|
+
Problem(
|
|
82
|
+
path="benchmark.runs_per_case",
|
|
83
|
+
message=f"{cfg.benchmark.runs_per_case} run per case cannot measure "
|
|
84
|
+
"consistency. Agents are not deterministic even at temperature 0; "
|
|
85
|
+
"3 is the floor for pass^k.",
|
|
86
|
+
line=locate(text, ("benchmark", "runs_per_case")),
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if cfg.benchmark.tool_mode == "live":
|
|
91
|
+
warnings.append(
|
|
92
|
+
Problem(
|
|
93
|
+
path="benchmark.tool_mode",
|
|
94
|
+
message="'live' executes your real tools against real APIs on every run",
|
|
95
|
+
line=locate(text, ("benchmark", "tool_mode")),
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if cfg.benchmark.fault_injection_rate > 0 and cfg.benchmark.tool_mode == "record":
|
|
100
|
+
warnings.append(
|
|
101
|
+
Problem(
|
|
102
|
+
path="benchmark.fault_injection_rate",
|
|
103
|
+
message="faults are not injected while recording (the recording would capture "
|
|
104
|
+
"the fault instead of the real response), so this setting has no effect",
|
|
105
|
+
line=locate(text, ("benchmark", "fault_injection_rate")),
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
empty = [c.case_id for c in dataset.cases if c.assertions.is_empty]
|
|
110
|
+
if empty:
|
|
111
|
+
warnings.append(
|
|
112
|
+
Problem(
|
|
113
|
+
path="dataset",
|
|
114
|
+
message=f"case(s) {', '.join(empty[:5])} declare no assertions and therefore "
|
|
115
|
+
"can never fail",
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if len(dataset.cases) >= 3 and not any(c.assertions.forbidden_tools for c in dataset.cases):
|
|
120
|
+
warnings.append(
|
|
121
|
+
Problem(
|
|
122
|
+
path="dataset",
|
|
123
|
+
message="no case declares forbidden_tools. A small model taking a destructive "
|
|
124
|
+
"action it was not asked to take is the failure worth catching first.",
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return warnings
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def as_payload(
|
|
132
|
+
loaded: LoadedConfig, dataset: Dataset, warnings: list[Problem]
|
|
133
|
+
) -> dict[str, Any]:
|
|
134
|
+
"""The same summary as a machine-readable dict.
|
|
135
|
+
|
|
136
|
+
Exists for one job, and it is worth naming: a CI workflow has to key its
|
|
137
|
+
cassette cache on something, and the only correct key is the *content* hash
|
|
138
|
+
of the dataset the cassettes were recorded for. A workflow that keyed on a
|
|
139
|
+
branch name, or on nothing, would restore a stale cache and then report
|
|
140
|
+
every uncovered path as a model regression.
|
|
141
|
+
|
|
142
|
+
The config hash rides along because it is what `gate` refuses to compare
|
|
143
|
+
across, so a workflow can print both and a reviewer can see at a glance
|
|
144
|
+
whether two runs were even the same experiment.
|
|
145
|
+
"""
|
|
146
|
+
cfg = loaded.config
|
|
147
|
+
return {
|
|
148
|
+
"schema": "slmeval.validate/1",
|
|
149
|
+
"ok": True,
|
|
150
|
+
"warnings": [
|
|
151
|
+
{"path": w.path, "message": w.message, "line": w.line} for w in warnings
|
|
152
|
+
],
|
|
153
|
+
"benchmark": cfg.benchmark.name,
|
|
154
|
+
"config": str(loaded.path),
|
|
155
|
+
"config_hash": cfg.config_hash(),
|
|
156
|
+
"dataset": str(dataset.path),
|
|
157
|
+
"dataset_hash": dataset.content_hash,
|
|
158
|
+
"cases": len(dataset),
|
|
159
|
+
"tags": list(dataset.tags),
|
|
160
|
+
"target": cfg.target.entrypoint,
|
|
161
|
+
"adapter": cfg.target.adapter,
|
|
162
|
+
"baseline": cfg.baseline.id,
|
|
163
|
+
"candidates": [c.id for c in cfg.candidates],
|
|
164
|
+
"runs_per_case": cfg.benchmark.runs_per_case,
|
|
165
|
+
"tool_mode": cfg.benchmark.tool_mode,
|
|
166
|
+
"executions": len(cfg.all_models) * len(dataset) * cfg.benchmark.runs_per_case,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _summary(loaded: LoadedConfig, dataset: Dataset) -> Table:
|
|
171
|
+
cfg = loaded.config
|
|
172
|
+
table = Table.grid(padding=(0, 2))
|
|
173
|
+
table.add_column(style="dim", no_wrap=True)
|
|
174
|
+
table.add_column(overflow="fold")
|
|
175
|
+
|
|
176
|
+
table.add_row("benchmark", f"{cfg.benchmark.name} [config {cfg.config_hash()}]")
|
|
177
|
+
table.add_row("config", str(loaded.path))
|
|
178
|
+
table.add_row(
|
|
179
|
+
"dataset",
|
|
180
|
+
f"{dataset.path.name} - {len(dataset)} case(s), hash {dataset.content_hash}"
|
|
181
|
+
+ (f", tags: {', '.join(dataset.tags)}" if dataset.tags else ""),
|
|
182
|
+
)
|
|
183
|
+
table.add_row("target", f"{cfg.target.entrypoint} (adapter: {cfg.target.adapter})")
|
|
184
|
+
table.add_row(
|
|
185
|
+
"matrix",
|
|
186
|
+
f"{len(cfg.all_models)} model(s) x {len(dataset)} case(s) x "
|
|
187
|
+
f"{cfg.benchmark.runs_per_case} run(s) = "
|
|
188
|
+
f"{len(cfg.all_models) * len(dataset) * cfg.benchmark.runs_per_case} executions",
|
|
189
|
+
)
|
|
190
|
+
table.add_row(
|
|
191
|
+
"tools",
|
|
192
|
+
f"mode={cfg.benchmark.tool_mode}, "
|
|
193
|
+
f"faults={cfg.benchmark.fault_injection_rate:.0%}",
|
|
194
|
+
)
|
|
195
|
+
table.add_row("baseline", cfg.baseline.describe())
|
|
196
|
+
for candidate in cfg.candidates:
|
|
197
|
+
table.add_row(f"candidate {candidate.id}", candidate.describe())
|
|
198
|
+
return table
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def register(app: typer.Typer) -> None:
|
|
202
|
+
@app.command()
|
|
203
|
+
@handle_errors
|
|
204
|
+
def validate(
|
|
205
|
+
config: Annotated[
|
|
206
|
+
Path | None,
|
|
207
|
+
typer.Option("--config", "-c", help="Path to eval.toml. Default: search upwards."),
|
|
208
|
+
] = None,
|
|
209
|
+
strict: Annotated[
|
|
210
|
+
bool,
|
|
211
|
+
typer.Option(
|
|
212
|
+
"--strict",
|
|
213
|
+
help="Import the target entrypoint and treat warnings as errors (for CI).",
|
|
214
|
+
),
|
|
215
|
+
] = False,
|
|
216
|
+
as_json: Annotated[
|
|
217
|
+
bool,
|
|
218
|
+
typer.Option(
|
|
219
|
+
"--json",
|
|
220
|
+
help="Emit the summary - including the config and dataset hashes - "
|
|
221
|
+
"as JSON. For CI cache keys and provenance checks.",
|
|
222
|
+
),
|
|
223
|
+
] = False,
|
|
224
|
+
) -> None:
|
|
225
|
+
"""Validate eval.toml, the dataset, and the links between them."""
|
|
226
|
+
out = console()
|
|
227
|
+
sym = symbols()
|
|
228
|
+
|
|
229
|
+
loaded = load_config(find_config(config))
|
|
230
|
+
dataset = load_dataset(require_dataset(loaded))
|
|
231
|
+
warnings = cross_check(loaded, dataset)
|
|
232
|
+
|
|
233
|
+
if as_json:
|
|
234
|
+
# print, not rich-print: this is data on its way into a pipe or a
|
|
235
|
+
# `$GITHUB_OUTPUT`, and Rich would wrap and style it into something
|
|
236
|
+
# no parser can read.
|
|
237
|
+
print(json.dumps(as_payload(loaded, dataset, warnings), indent=2))
|
|
238
|
+
if warnings and strict:
|
|
239
|
+
raise typer.Exit(exit_codes.USAGE)
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
out.print()
|
|
243
|
+
out.print(_summary(loaded, dataset))
|
|
244
|
+
|
|
245
|
+
if strict:
|
|
246
|
+
func = resolve_entrypoint(loaded)
|
|
247
|
+
kind = "async" if inspect.iscoroutinefunction(func) else "sync"
|
|
248
|
+
out.print()
|
|
249
|
+
out.print(
|
|
250
|
+
Text(f" {sym.ok} ", style="green"),
|
|
251
|
+
end="",
|
|
252
|
+
)
|
|
253
|
+
out.print(
|
|
254
|
+
Text(
|
|
255
|
+
f"imported {loaded.config.target.entrypoint} ({kind})",
|
|
256
|
+
style="dim",
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
if kind == "sync":
|
|
260
|
+
warnings.append(
|
|
261
|
+
Problem(
|
|
262
|
+
path="target.entrypoint",
|
|
263
|
+
message="target is a plain function; it will be run in a worker thread, "
|
|
264
|
+
"where contextvars-based tool interception is more fragile. An "
|
|
265
|
+
"`async def` target is strongly preferred.",
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
out.print()
|
|
270
|
+
for warning in warnings:
|
|
271
|
+
out.print(Text(f" {sym.warn} {warning.path}", style="yellow"), end="")
|
|
272
|
+
out.print(Text(f" {sym.arrow} {warning.message}"))
|
|
273
|
+
if warning.line:
|
|
274
|
+
out.print(
|
|
275
|
+
Text(
|
|
276
|
+
f" {loaded.path.name}:{warning.line}",
|
|
277
|
+
style="dim",
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
if warnings:
|
|
281
|
+
out.print()
|
|
282
|
+
|
|
283
|
+
count = len(warnings)
|
|
284
|
+
if count and strict:
|
|
285
|
+
out.print(
|
|
286
|
+
Text(
|
|
287
|
+
f" {sym.fail} {count} warning(s), and --strict was given",
|
|
288
|
+
style="bold red",
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
out.print()
|
|
292
|
+
raise typer.Exit(exit_codes.USAGE)
|
|
293
|
+
|
|
294
|
+
label = f"valid - {count} warning(s)" if count else "valid"
|
|
295
|
+
out.print(Text(f" {sym.ok} {label}", style="bold green"))
|
|
296
|
+
out.print()
|
slmeval/cli/loading.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Getting from a command-line invocation to a loaded project.
|
|
2
|
+
|
|
3
|
+
Four commands need the same five things - config, dataset, target, trace store,
|
|
4
|
+
cassette store - and they have to fail in the same order every time: config
|
|
5
|
+
before dataset, dataset before importing the user's agent. A typo in
|
|
6
|
+
``eval.toml`` must not be reported as an import error, and importing the user's
|
|
7
|
+
module before validating their dataset makes a trivial mistake arrive as a
|
|
8
|
+
stack trace from somebody else's code.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from slmeval.config.loader import LoadedConfig, find_config, load_config
|
|
19
|
+
from slmeval.config.models import EvalConfig
|
|
20
|
+
from slmeval.core.adapter import Target, load_target
|
|
21
|
+
from slmeval.dataset.loader import Dataset, load_dataset
|
|
22
|
+
from slmeval.errors import ConfigError, DatasetError
|
|
23
|
+
from slmeval.eval.cost import PriceTable
|
|
24
|
+
from slmeval.sandbox.store import CassetteStore
|
|
25
|
+
from slmeval.storage.db import RunStore
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class Project:
|
|
30
|
+
"""Everything a run needs, opened and ready."""
|
|
31
|
+
|
|
32
|
+
loaded: LoadedConfig
|
|
33
|
+
dataset: Dataset
|
|
34
|
+
target: Target
|
|
35
|
+
store: RunStore
|
|
36
|
+
cassettes: CassetteStore
|
|
37
|
+
prices: PriceTable
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def config(self) -> EvalConfig:
|
|
41
|
+
return self.loaded.config
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def root(self) -> Path:
|
|
45
|
+
return self.loaded.root
|
|
46
|
+
|
|
47
|
+
def close(self) -> None:
|
|
48
|
+
# Suppressed individually: a failure closing one store must not leave
|
|
49
|
+
# the other open, and neither is worth masking the error that brought
|
|
50
|
+
# us here.
|
|
51
|
+
with contextlib.suppress(Exception):
|
|
52
|
+
self.cassettes.close()
|
|
53
|
+
with contextlib.suppress(Exception):
|
|
54
|
+
self.store.close()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@contextlib.contextmanager
|
|
58
|
+
def open_project(
|
|
59
|
+
config_path: Path | None = None,
|
|
60
|
+
*,
|
|
61
|
+
gpu_hourly_usd: float | None = None,
|
|
62
|
+
load_agent: bool = True,
|
|
63
|
+
) -> Iterator[Project]:
|
|
64
|
+
"""Open a project, closing both stores on the way out.
|
|
65
|
+
|
|
66
|
+
``load_agent=False`` skips importing the user's module, for commands that
|
|
67
|
+
only read stored results. ``slmeval report`` must work on a checkout whose
|
|
68
|
+
agent no longer imports - the results are still valid, and refusing to
|
|
69
|
+
print them would be the tool losing data it already has.
|
|
70
|
+
"""
|
|
71
|
+
path = find_config(config_path)
|
|
72
|
+
loaded = load_config(path)
|
|
73
|
+
dataset = _dataset(loaded)
|
|
74
|
+
|
|
75
|
+
target = (
|
|
76
|
+
load_target(
|
|
77
|
+
loaded.config.target.entrypoint,
|
|
78
|
+
adapter=loaded.config.target.adapter,
|
|
79
|
+
root=loaded.root,
|
|
80
|
+
# Resolved against eval.toml, not the working directory: the same
|
|
81
|
+
# command has to mean the same thing run from the project root and
|
|
82
|
+
# from two levels down.
|
|
83
|
+
extra_paths=[
|
|
84
|
+
p if p.is_absolute() else (loaded.root / p).resolve()
|
|
85
|
+
for p in loaded.config.target.sys_path
|
|
86
|
+
],
|
|
87
|
+
)
|
|
88
|
+
if load_agent
|
|
89
|
+
else Target(entrypoint=loaded.config.target.entrypoint, func=_unloaded, is_async=False)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
store = RunStore.for_project(loaded.root)
|
|
93
|
+
cassettes = CassetteStore.for_project(loaded.root)
|
|
94
|
+
project = Project(
|
|
95
|
+
loaded=loaded,
|
|
96
|
+
dataset=dataset,
|
|
97
|
+
target=target,
|
|
98
|
+
store=store,
|
|
99
|
+
cassettes=cassettes,
|
|
100
|
+
prices=PriceTable.load(loaded.root, gpu_hourly_usd=gpu_hourly_usd),
|
|
101
|
+
)
|
|
102
|
+
try:
|
|
103
|
+
yield project
|
|
104
|
+
finally:
|
|
105
|
+
project.close()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
LATEST = "latest"
|
|
109
|
+
"""What every command accepts in place of a run id."""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def resolve_run(store: RunStore, run_id: str = LATEST) -> str:
|
|
113
|
+
"""``latest``, a full run id, or an unambiguous prefix, resolved to one id.
|
|
114
|
+
|
|
115
|
+
Shared by ``report``, ``show`` and ``gate`` rather than written three
|
|
116
|
+
times: all three have to treat ``latest`` identically, and a prefix that
|
|
117
|
+
matches two runs has to be refused rather than resolved to whichever one
|
|
118
|
+
sorted first - a report for a run the user was not asking about is worse
|
|
119
|
+
than an error.
|
|
120
|
+
"""
|
|
121
|
+
if run_id == LATEST:
|
|
122
|
+
latest = store.latest_run()
|
|
123
|
+
if latest is None:
|
|
124
|
+
raise ConfigError(
|
|
125
|
+
"no runs stored yet",
|
|
126
|
+
hint="run `slmeval run` first",
|
|
127
|
+
)
|
|
128
|
+
return latest.run_id
|
|
129
|
+
return store.resolve_run_id(run_id)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _dataset(loaded: LoadedConfig) -> Dataset:
|
|
133
|
+
path = loaded.dataset_path
|
|
134
|
+
if not path.is_file():
|
|
135
|
+
raise DatasetError(
|
|
136
|
+
f"dataset not found: {path}",
|
|
137
|
+
hint=(
|
|
138
|
+
f"[benchmark].dataset in {loaded.path.name} points at "
|
|
139
|
+
f"{loaded.config.benchmark.dataset}, resolved against {loaded.root}"
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
return load_dataset(path)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _unloaded(_: object) -> object: # pragma: no cover - never called
|
|
146
|
+
raise ConfigError("the agent was not loaded for this command")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
__all__ = ["LATEST", "Project", "open_project", "resolve_run"]
|
slmeval/cli/main.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""CLI entrypoint.
|
|
2
|
+
|
|
3
|
+
Commands are registered in the order they should appear in `--help`. The planned
|
|
4
|
+
ones come last and stay hidden from it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from slmeval import __version__
|
|
14
|
+
from slmeval.cli.commands import (
|
|
15
|
+
baseline_cmd,
|
|
16
|
+
cache_cmd,
|
|
17
|
+
dataset_cmd,
|
|
18
|
+
doctor_cmd,
|
|
19
|
+
gate_cmd,
|
|
20
|
+
init_cmd,
|
|
21
|
+
judge_cmd,
|
|
22
|
+
record_cmd,
|
|
23
|
+
report_cmd,
|
|
24
|
+
run_cmd,
|
|
25
|
+
show_cmd,
|
|
26
|
+
stubs,
|
|
27
|
+
validate_cmd,
|
|
28
|
+
)
|
|
29
|
+
from slmeval.cli.render import console
|
|
30
|
+
|
|
31
|
+
app = typer.Typer(
|
|
32
|
+
name="slmeval",
|
|
33
|
+
no_args_is_help=True,
|
|
34
|
+
add_completion=True,
|
|
35
|
+
rich_markup_mode="rich",
|
|
36
|
+
# Locals can hold a resolved API key, so they never go into a traceback.
|
|
37
|
+
pretty_exceptions_show_locals=False,
|
|
38
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _version_callback(value: bool) -> None:
|
|
43
|
+
if value:
|
|
44
|
+
console().print(f"slmeval {__version__}")
|
|
45
|
+
raise typer.Exit()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.callback()
|
|
49
|
+
def cli(
|
|
50
|
+
version: Annotated[
|
|
51
|
+
bool,
|
|
52
|
+
typer.Option(
|
|
53
|
+
"--version",
|
|
54
|
+
"-V",
|
|
55
|
+
callback=_version_callback,
|
|
56
|
+
is_eager=True,
|
|
57
|
+
help="Show the version and exit.",
|
|
58
|
+
),
|
|
59
|
+
] = False,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Benchmark agent workflows across small language models.
|
|
62
|
+
|
|
63
|
+
Record your agent's tool calls once, replay them deterministically against
|
|
64
|
+
every candidate model, and see what each swap costs you in reliability.
|
|
65
|
+
|
|
66
|
+
Start with [cyan]slmeval init[/cyan], then [cyan]slmeval doctor[/cyan].
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
init_cmd.register(app)
|
|
71
|
+
doctor_cmd.register(app)
|
|
72
|
+
validate_cmd.register(app)
|
|
73
|
+
dataset_cmd.register(app)
|
|
74
|
+
record_cmd.register(app)
|
|
75
|
+
run_cmd.register(app)
|
|
76
|
+
report_cmd.register(app)
|
|
77
|
+
show_cmd.register(app)
|
|
78
|
+
gate_cmd.register(app)
|
|
79
|
+
baseline_cmd.register(app)
|
|
80
|
+
judge_cmd.register(app)
|
|
81
|
+
cache_cmd.register(app)
|
|
82
|
+
stubs.register(app)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main() -> None:
|
|
86
|
+
"""Console-script entrypoint."""
|
|
87
|
+
app()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__": # pragma: no cover
|
|
91
|
+
main()
|