downshift 0.1.0.dev0__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.
- downshift/__init__.py +3 -0
- downshift/audit.py +188 -0
- downshift/cli.py +755 -0
- downshift/config.py +348 -0
- downshift/cost.py +212 -0
- downshift/decide.py +220 -0
- downshift/evalgen.py +220 -0
- downshift/evals.py +395 -0
- downshift/llm.py +171 -0
- downshift/py.typed +0 -0
- downshift/report.py +380 -0
- downshift/resolve.py +439 -0
- downshift/runner.py +312 -0
- downshift/scanner.py +310 -0
- downshift/schema.py +356 -0
- downshift/scorer.py +219 -0
- downshift-0.1.0.dev0.dist-info/METADATA +24 -0
- downshift-0.1.0.dev0.dist-info/RECORD +21 -0
- downshift-0.1.0.dev0.dist-info/WHEEL +4 -0
- downshift-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- downshift-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
downshift/cli.py
ADDED
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
"""Downshift command line interface."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from downshift import __version__
|
|
12
|
+
from downshift.audit import Comparison, compare_scans, validate_file
|
|
13
|
+
from downshift.config import ConfigError, resolve_config
|
|
14
|
+
from downshift.evalgen import EvalGenSkip, generate_eval_set, write_eval_set
|
|
15
|
+
from downshift.evals import (
|
|
16
|
+
EVAL_SUFFIX,
|
|
17
|
+
EvalError,
|
|
18
|
+
EvalReport,
|
|
19
|
+
EvalSet,
|
|
20
|
+
check_eval_dir,
|
|
21
|
+
load_eval_set,
|
|
22
|
+
site_placeholders,
|
|
23
|
+
slug_for,
|
|
24
|
+
validate_eval_set,
|
|
25
|
+
)
|
|
26
|
+
from downshift.llm import LLMClient, LLMError, OpenAICompatClient
|
|
27
|
+
from downshift.report import ReportError, build_report, render_markdown
|
|
28
|
+
from downshift.runner import ResultRow, Runner, RunSummary, rescore_site, results_path
|
|
29
|
+
from downshift.scanner import scan_path
|
|
30
|
+
from downshift.schema import CallSite, ScanResult, SchemaError
|
|
31
|
+
from downshift.scorer import Judge
|
|
32
|
+
|
|
33
|
+
DEFAULT_OUT = Path(".downshift") / "callsites.json"
|
|
34
|
+
|
|
35
|
+
app = typer.Typer(
|
|
36
|
+
name="downshift",
|
|
37
|
+
help=(
|
|
38
|
+
"Find every LLM call in your repo, prove which ones can use cheaper "
|
|
39
|
+
"models, and show the cost impact of every PR."
|
|
40
|
+
),
|
|
41
|
+
no_args_is_help=True,
|
|
42
|
+
add_completion=False,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _version_callback(value: bool) -> None:
|
|
47
|
+
if value:
|
|
48
|
+
typer.echo(f"downshift {__version__}")
|
|
49
|
+
raise typer.Exit()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.callback()
|
|
53
|
+
def main_callback(
|
|
54
|
+
version: bool = typer.Option(
|
|
55
|
+
False,
|
|
56
|
+
"--version",
|
|
57
|
+
callback=_version_callback,
|
|
58
|
+
is_eager=True,
|
|
59
|
+
help="Show the version and exit.",
|
|
60
|
+
),
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Downshift: cut LLM costs per PR."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _not_implemented(name: str) -> None:
|
|
66
|
+
typer.echo(f"`downshift {name}` is not implemented yet.", err=True)
|
|
67
|
+
raise typer.Exit(code=1)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _fail(message: str) -> None:
|
|
71
|
+
typer.echo(f"Error: {message}", err=True)
|
|
72
|
+
raise typer.Exit(code=2)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# --- scan ---------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command()
|
|
79
|
+
def scan(
|
|
80
|
+
path: Path = typer.Argument(Path("."), help="Repository directory or Python file to scan."),
|
|
81
|
+
config: Path | None = typer.Option(
|
|
82
|
+
None,
|
|
83
|
+
"--config",
|
|
84
|
+
"-c",
|
|
85
|
+
help="Config file. Default: downshift.yaml in PATH, if present.",
|
|
86
|
+
),
|
|
87
|
+
out: Path | None = typer.Option(
|
|
88
|
+
None,
|
|
89
|
+
"--out",
|
|
90
|
+
"-o",
|
|
91
|
+
help="Where to write callsites.json. Default: PATH/.downshift/callsites.json.",
|
|
92
|
+
),
|
|
93
|
+
as_json: bool = typer.Option(
|
|
94
|
+
False, "--json", help="Print the JSON to stdout instead of a table."
|
|
95
|
+
),
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Find every LLM call site in a repository."""
|
|
98
|
+
try:
|
|
99
|
+
cfg = resolve_config(config, path)
|
|
100
|
+
result = scan_path(path, cfg.scan)
|
|
101
|
+
except (ConfigError, FileNotFoundError) as exc:
|
|
102
|
+
_fail(str(exc))
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
if as_json:
|
|
106
|
+
typer.echo(result.to_json(), nl=False)
|
|
107
|
+
if out is not None:
|
|
108
|
+
result.write(out)
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
target = out if out is not None else _default_out(path)
|
|
112
|
+
result.write(target)
|
|
113
|
+
_print_table(result)
|
|
114
|
+
|
|
115
|
+
summary = result.summary()
|
|
116
|
+
total = summary["call_sites"]
|
|
117
|
+
typer.echo(
|
|
118
|
+
f"{total} call sites in {summary['files_scanned']} files, "
|
|
119
|
+
f"models resolved {summary['models_resolved']}/{total}, "
|
|
120
|
+
f"prompts resolved {summary['prompts_resolved']}/{total}"
|
|
121
|
+
)
|
|
122
|
+
for warning in result.warnings:
|
|
123
|
+
typer.echo(f"warning: {warning}", err=True)
|
|
124
|
+
typer.echo(f"Wrote {target}")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _default_out(path: Path) -> Path:
|
|
128
|
+
base = path if path.is_dir() else path.parent
|
|
129
|
+
return base / DEFAULT_OUT
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _print_table(result: ScanResult) -> None:
|
|
133
|
+
table = Table(title=f"LLM call sites in {result.root}")
|
|
134
|
+
table.add_column("File", no_wrap=True)
|
|
135
|
+
table.add_column("Function", no_wrap=True)
|
|
136
|
+
table.add_column("Line", justify="right")
|
|
137
|
+
table.add_column("Model", no_wrap=True)
|
|
138
|
+
table.add_column("Source")
|
|
139
|
+
table.add_column("Prompt")
|
|
140
|
+
table.add_column("Output")
|
|
141
|
+
for site in result.call_sites:
|
|
142
|
+
table.add_row(
|
|
143
|
+
site.file,
|
|
144
|
+
site.function,
|
|
145
|
+
str(site.line),
|
|
146
|
+
site.model.value or "[red]?[/red]",
|
|
147
|
+
site.model.source,
|
|
148
|
+
"known" if site.prompt_resolved else "[yellow]runtime[/yellow]",
|
|
149
|
+
site.output_format,
|
|
150
|
+
)
|
|
151
|
+
Console().print(table)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# --- validate -----------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@app.command()
|
|
158
|
+
def validate(
|
|
159
|
+
path: Path = typer.Argument(..., help="callsites or audit JSON file to check."),
|
|
160
|
+
strict: bool = typer.Option(False, "--strict", help="Treat warnings as errors."),
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Check a callsites or audit file against the schema."""
|
|
163
|
+
if not path.is_file():
|
|
164
|
+
_fail(f"file not found: {path}")
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
report = validate_file(path)
|
|
168
|
+
result = report.result
|
|
169
|
+
if report.error is not None or result is None:
|
|
170
|
+
typer.echo(f"Invalid: {report.error}", err=True)
|
|
171
|
+
raise typer.Exit(code=1)
|
|
172
|
+
|
|
173
|
+
summary = result.summary()
|
|
174
|
+
total = summary["call_sites"]
|
|
175
|
+
typer.echo(
|
|
176
|
+
f"Valid: {path} ({total} call sites, generated by {result.generated_by}, "
|
|
177
|
+
f"models resolved {summary['models_resolved']}/{total}, "
|
|
178
|
+
f"prompts resolved {summary['prompts_resolved']}/{total})"
|
|
179
|
+
)
|
|
180
|
+
for warning in report.warnings:
|
|
181
|
+
typer.echo(f"warning: {warning}", err=True)
|
|
182
|
+
if strict and report.warnings:
|
|
183
|
+
raise typer.Exit(code=1)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# --- compare ------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@app.command()
|
|
190
|
+
def compare(
|
|
191
|
+
ast_file: Path = typer.Argument(..., help="Scan output from `downshift scan`."),
|
|
192
|
+
audit_file: Path = typer.Argument(..., help="Audit output from the Bob auditor."),
|
|
193
|
+
as_json: bool = typer.Option(False, "--json", help="Print the comparison as JSON."),
|
|
194
|
+
) -> None:
|
|
195
|
+
"""Compare the ast scan with a Bob audit, side by side."""
|
|
196
|
+
try:
|
|
197
|
+
before = ScanResult.load(ast_file)
|
|
198
|
+
after = ScanResult.load(audit_file)
|
|
199
|
+
except SchemaError as exc:
|
|
200
|
+
_fail(str(exc))
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
comparison = compare_scans(before, after)
|
|
204
|
+
if as_json:
|
|
205
|
+
typer.echo(json.dumps(comparison.to_dict(), indent=2))
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
left, right = before.generated_by, after.generated_by
|
|
209
|
+
if left == right:
|
|
210
|
+
left, right = "before", "after"
|
|
211
|
+
_print_comparison(comparison, left, right)
|
|
212
|
+
|
|
213
|
+
b, a = before.summary(), after.summary()
|
|
214
|
+
typer.echo(
|
|
215
|
+
f"{left}: {b['call_sites']} call sites, {b['models_resolved']} models resolved | "
|
|
216
|
+
f"{right}: {a['call_sites']} call sites, {a['models_resolved']} models resolved"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _print_comparison(comparison: Comparison, left: str, right: str) -> None:
|
|
221
|
+
console = Console()
|
|
222
|
+
metrics = Table(title="Scan vs audit")
|
|
223
|
+
metrics.add_column("Metric")
|
|
224
|
+
metrics.add_column(left, justify="right")
|
|
225
|
+
metrics.add_column(right, justify="right")
|
|
226
|
+
for metric in comparison.metrics:
|
|
227
|
+
metrics.add_row(metric.name, str(metric.ast), str(metric.audit))
|
|
228
|
+
console.print(metrics)
|
|
229
|
+
|
|
230
|
+
changes = Table(title="Call site changes")
|
|
231
|
+
changes.add_column("Call site", no_wrap=True)
|
|
232
|
+
changes.add_column("Change")
|
|
233
|
+
changes.add_column("Detail")
|
|
234
|
+
for change in comparison.changes:
|
|
235
|
+
changes.add_row(change.id, change.kind, change.detail)
|
|
236
|
+
console.print(changes)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# --- check-evals --------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@app.command("check-evals")
|
|
243
|
+
def check_evals(
|
|
244
|
+
directory: Path = typer.Argument(..., help="Folder of <slug>.jsonl eval files."),
|
|
245
|
+
callsites: Path = typer.Option(
|
|
246
|
+
..., "--callsites", help="Audit (or callsites) JSON the eval files belong to."
|
|
247
|
+
),
|
|
248
|
+
strict: bool = typer.Option(False, "--strict", help="Treat warnings as errors."),
|
|
249
|
+
) -> None:
|
|
250
|
+
"""Check eval files against the call sites they test."""
|
|
251
|
+
if not directory.is_dir():
|
|
252
|
+
_fail(f"folder not found: {directory}")
|
|
253
|
+
return
|
|
254
|
+
try:
|
|
255
|
+
result = ScanResult.load(callsites)
|
|
256
|
+
except SchemaError as exc:
|
|
257
|
+
_fail(str(exc))
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
reports = check_eval_dir(directory, result.call_sites)
|
|
261
|
+
_print_eval_reports(reports)
|
|
262
|
+
|
|
263
|
+
errors = sum(len(r.errors) for r in reports)
|
|
264
|
+
warnings = sum(len(r.warnings) for r in reports)
|
|
265
|
+
cases = sum(r.cases for r in reports)
|
|
266
|
+
files = sum(1 for r in reports if r.path.exists())
|
|
267
|
+
for r in reports:
|
|
268
|
+
for message in r.errors:
|
|
269
|
+
typer.echo(f"error: {r.path.name}: {message}", err=True)
|
|
270
|
+
for message in r.warnings:
|
|
271
|
+
typer.echo(f"warning: {r.path.name}: {message}", err=True)
|
|
272
|
+
typer.echo(f"{files} eval files, {cases} cases, {errors} errors, {warnings} warnings")
|
|
273
|
+
if errors or (strict and warnings):
|
|
274
|
+
raise typer.Exit(code=1)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _print_eval_reports(reports: list[EvalReport]) -> None:
|
|
278
|
+
table = Table(title="Eval sets")
|
|
279
|
+
table.add_column("Call site", overflow="fold")
|
|
280
|
+
table.add_column("Grading")
|
|
281
|
+
table.add_column("Cases", justify="right")
|
|
282
|
+
table.add_column("Status")
|
|
283
|
+
for r in reports:
|
|
284
|
+
if r.errors:
|
|
285
|
+
status = f"[red]{len(r.errors)} errors[/red]"
|
|
286
|
+
elif r.warnings:
|
|
287
|
+
status = f"[yellow]{len(r.warnings)} warnings[/yellow]"
|
|
288
|
+
else:
|
|
289
|
+
status = "[green]ok[/green]"
|
|
290
|
+
table.add_row(r.site_id or "?", r.grading or "-", str(r.cases), status)
|
|
291
|
+
Console().print(table)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# --- evalgen ------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _make_client(provider_base_url: str, api_key: str) -> LLMClient:
|
|
298
|
+
return OpenAICompatClient(provider_base_url, api_key)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _make_judge_client(base_url: str, api_key: str) -> LLMClient:
|
|
302
|
+
"""Client for a hosted judge; retries rate limits (429) with backoff."""
|
|
303
|
+
from openai import OpenAI
|
|
304
|
+
|
|
305
|
+
return OpenAICompatClient(
|
|
306
|
+
base_url,
|
|
307
|
+
api_key,
|
|
308
|
+
client=OpenAI(base_url=base_url, api_key=api_key, timeout=120.0, max_retries=8),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _build_judge(
|
|
313
|
+
client: LLMClient, model: str, base_url: str | None, key_env: str, max_tokens: int
|
|
314
|
+
) -> Judge:
|
|
315
|
+
if base_url is None:
|
|
316
|
+
return Judge(client, model, max_tokens=max_tokens)
|
|
317
|
+
api_key = os.environ.get(key_env, "")
|
|
318
|
+
if not api_key:
|
|
319
|
+
raise ValueError(f"judge endpoint {base_url} needs an API key in ${key_env}")
|
|
320
|
+
return Judge(_make_judge_client(base_url, api_key), model, max_tokens=max_tokens)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@app.command()
|
|
324
|
+
def evalgen(
|
|
325
|
+
callsites: Path = typer.Option(..., "--callsites", help="Audit (or callsites) JSON."),
|
|
326
|
+
out: Path = typer.Option(..., "--out", "-o", help="Folder to write <slug>.jsonl files to."),
|
|
327
|
+
site: list[str] = typer.Option([], "--site", help="Only this call site id. Repeatable."),
|
|
328
|
+
model: str | None = typer.Option(None, "--model", help="Default: models.judge or baseline."),
|
|
329
|
+
count: int = typer.Option(20, "--count", min=1, help="Cases per call site."),
|
|
330
|
+
shared: list[str] = typer.Option(
|
|
331
|
+
[], "--shared", help="KEY=FILE, a fixed input read from a file. Repeatable."
|
|
332
|
+
),
|
|
333
|
+
config: Path | None = typer.Option(None, "--config", "-c", help="Config file."),
|
|
334
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing eval files."),
|
|
335
|
+
) -> None:
|
|
336
|
+
"""Generate an eval set for each call site with a configured model."""
|
|
337
|
+
try:
|
|
338
|
+
result = ScanResult.load(callsites)
|
|
339
|
+
cfg = resolve_config(config, callsites.parent)
|
|
340
|
+
client = _make_client(cfg.provider.base_url, cfg.provider.api_key())
|
|
341
|
+
except (SchemaError, ConfigError) as exc:
|
|
342
|
+
_fail(str(exc))
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
shared_files: dict[str, Path] = {}
|
|
346
|
+
for item in shared:
|
|
347
|
+
key, sep, file = item.partition("=")
|
|
348
|
+
if not sep or not key or not Path(file).is_file():
|
|
349
|
+
_fail(f"--shared expects KEY=FILE with an existing file, got {item!r}")
|
|
350
|
+
return
|
|
351
|
+
shared_files[key] = Path(file)
|
|
352
|
+
shared_text = {k: p.read_text(encoding="utf-8") for k, p in shared_files.items()}
|
|
353
|
+
|
|
354
|
+
sites = result.call_sites
|
|
355
|
+
if site:
|
|
356
|
+
known = {s.id for s in sites}
|
|
357
|
+
unknown = [s for s in site if s not in known]
|
|
358
|
+
if unknown:
|
|
359
|
+
_fail(f"unknown call site id(s): {', '.join(unknown)}")
|
|
360
|
+
return
|
|
361
|
+
sites = [s for s in sites if s.id in site]
|
|
362
|
+
|
|
363
|
+
use_model = model or cfg.models.judge_model
|
|
364
|
+
failed = False
|
|
365
|
+
for call_site in sites:
|
|
366
|
+
path = out / f"{slug_for(call_site.id)}{EVAL_SUFFIX}"
|
|
367
|
+
if path.exists() and not force:
|
|
368
|
+
typer.echo(f"skip {call_site.id}: {path} exists (use --force)")
|
|
369
|
+
continue
|
|
370
|
+
try:
|
|
371
|
+
gen = generate_eval_set(client, use_model, call_site, count=count, shared=shared_text)
|
|
372
|
+
except EvalGenSkip as exc:
|
|
373
|
+
typer.echo(f"skip {call_site.id}: {exc}")
|
|
374
|
+
continue
|
|
375
|
+
except LLMError as exc:
|
|
376
|
+
typer.echo(f"error {call_site.id}: {exc}", err=True)
|
|
377
|
+
failed = True
|
|
378
|
+
continue
|
|
379
|
+
if not gen.cases:
|
|
380
|
+
typer.echo(
|
|
381
|
+
f"error {call_site.id}: no valid cases after {gen.attempts} attempts", err=True
|
|
382
|
+
)
|
|
383
|
+
failed = True
|
|
384
|
+
continue
|
|
385
|
+
names = set(site_placeholders(call_site))
|
|
386
|
+
used = {k: p for k, p in shared_files.items() if k in names}
|
|
387
|
+
write_eval_set(path, gen.cases, used)
|
|
388
|
+
typer.echo(
|
|
389
|
+
f"wrote {path} ({len(gen.cases)} cases, {len(gen.dropped)} dropped, "
|
|
390
|
+
f"{gen.attempts} calls, model {use_model})"
|
|
391
|
+
)
|
|
392
|
+
if failed:
|
|
393
|
+
raise typer.Exit(code=1)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# --- run ----------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@app.command()
|
|
400
|
+
def run(
|
|
401
|
+
callsites: Path = typer.Option(..., "--callsites", help="Audit (or callsites) JSON."),
|
|
402
|
+
evals: Path | None = typer.Option(
|
|
403
|
+
None, "--evals", help="Eval folder. Default: evals/ next to --callsites."
|
|
404
|
+
),
|
|
405
|
+
out: Path | None = typer.Option(
|
|
406
|
+
None, "--out", "-o", help="Results folder. Default: results/ next to --callsites."
|
|
407
|
+
),
|
|
408
|
+
model: list[str] = typer.Option(
|
|
409
|
+
[], "--model", help="Model to run. Repeatable. Default: baseline + candidates."
|
|
410
|
+
),
|
|
411
|
+
site: list[str] = typer.Option([], "--site", help="Only this call site id. Repeatable."),
|
|
412
|
+
limit: int | None = typer.Option(
|
|
413
|
+
None, "--limit", min=1, help="Only the first N cases per call site."
|
|
414
|
+
),
|
|
415
|
+
config: Path | None = typer.Option(None, "--config", "-c", help="Config file."),
|
|
416
|
+
warmup: bool = typer.Option(
|
|
417
|
+
True, "--warmup/--no-warmup", help="One untimed call per model before timing."
|
|
418
|
+
),
|
|
419
|
+
judge_model: str | None = typer.Option(
|
|
420
|
+
None, "--judge-model", help="Judge model. Default: models.judge or baseline."
|
|
421
|
+
),
|
|
422
|
+
judge_base_url: str | None = typer.Option(
|
|
423
|
+
None, "--judge-base-url", help="Separate OpenAI-compatible endpoint for the judge."
|
|
424
|
+
),
|
|
425
|
+
judge_api_key_env: str = typer.Option(
|
|
426
|
+
"JUDGE_API_KEY", "--judge-api-key-env", help="Env var with the judge API key."
|
|
427
|
+
),
|
|
428
|
+
judge_max_tokens: int = typer.Option(
|
|
429
|
+
1024, "--judge-max-tokens", min=16, help="Max tokens per judge reply."
|
|
430
|
+
),
|
|
431
|
+
) -> None:
|
|
432
|
+
"""Run each call site's evals on the baseline and candidate models, and score them."""
|
|
433
|
+
try:
|
|
434
|
+
result = ScanResult.load(callsites)
|
|
435
|
+
cfg = resolve_config(config, callsites.parent)
|
|
436
|
+
client = _make_client(cfg.provider.base_url, cfg.provider.api_key())
|
|
437
|
+
judge = _build_judge(
|
|
438
|
+
client,
|
|
439
|
+
judge_model or cfg.models.judge_model,
|
|
440
|
+
judge_base_url,
|
|
441
|
+
judge_api_key_env,
|
|
442
|
+
judge_max_tokens,
|
|
443
|
+
)
|
|
444
|
+
except (SchemaError, ConfigError, ValueError) as exc:
|
|
445
|
+
_fail(str(exc))
|
|
446
|
+
return
|
|
447
|
+
|
|
448
|
+
evals_dir = evals if evals is not None else callsites.parent / "evals"
|
|
449
|
+
results_dir = out if out is not None else callsites.parent / "results"
|
|
450
|
+
if not evals_dir.is_dir():
|
|
451
|
+
_fail(f"folder not found: {evals_dir}")
|
|
452
|
+
return
|
|
453
|
+
|
|
454
|
+
sites = result.call_sites
|
|
455
|
+
if site:
|
|
456
|
+
known = {s.id for s in sites}
|
|
457
|
+
unknown = [s for s in site if s not in known]
|
|
458
|
+
if unknown:
|
|
459
|
+
_fail(f"unknown call site id(s): {', '.join(unknown)}")
|
|
460
|
+
return
|
|
461
|
+
sites = [s for s in sites if s.id in site]
|
|
462
|
+
|
|
463
|
+
tasks = _load_run_tasks(sites, evals_dir)
|
|
464
|
+
if not tasks:
|
|
465
|
+
_fail("nothing to run: no call site has a valid eval file")
|
|
466
|
+
return
|
|
467
|
+
|
|
468
|
+
models = model or [cfg.models.baseline, *cfg.models.candidates]
|
|
469
|
+
runner = Runner(
|
|
470
|
+
client,
|
|
471
|
+
results_dir=results_dir,
|
|
472
|
+
judge=judge,
|
|
473
|
+
limit=limit,
|
|
474
|
+
warmup=warmup,
|
|
475
|
+
on_case=_case_mark,
|
|
476
|
+
)
|
|
477
|
+
summaries: list[RunSummary] = []
|
|
478
|
+
failed = False
|
|
479
|
+
for name in models:
|
|
480
|
+
for call_site, eval_set in tasks:
|
|
481
|
+
typer.echo(f"{name} {call_site.id} ", nl=False)
|
|
482
|
+
try:
|
|
483
|
+
summary = runner.run_site(call_site, eval_set, name)
|
|
484
|
+
except LLMError as exc:
|
|
485
|
+
typer.echo("")
|
|
486
|
+
typer.echo(f"error {name}: {exc}; skipping this model", err=True)
|
|
487
|
+
failed = True
|
|
488
|
+
break
|
|
489
|
+
summaries.append(summary)
|
|
490
|
+
typer.echo(f" {summary.passed}/{summary.cases} passed, {summary.new} new")
|
|
491
|
+
|
|
492
|
+
_print_run_summary(summaries)
|
|
493
|
+
errors = sum(s.errors for s in summaries)
|
|
494
|
+
typer.echo(f"{len(summaries)} site/model runs, {errors} errors. Results in {results_dir}")
|
|
495
|
+
if failed or errors:
|
|
496
|
+
raise typer.Exit(code=1)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _load_run_tasks(sites: list[CallSite], evals_dir: Path) -> list[tuple[CallSite, EvalSet]]:
|
|
500
|
+
tasks: list[tuple[CallSite, EvalSet]] = []
|
|
501
|
+
for call_site in sites:
|
|
502
|
+
path = evals_dir / f"{slug_for(call_site.id)}{EVAL_SUFFIX}"
|
|
503
|
+
if not path.is_file():
|
|
504
|
+
typer.echo(f"skip {call_site.id}: no eval file at {path}")
|
|
505
|
+
continue
|
|
506
|
+
try:
|
|
507
|
+
eval_set = load_eval_set(path)
|
|
508
|
+
except EvalError as exc:
|
|
509
|
+
typer.echo(f"skip {call_site.id}: {exc}")
|
|
510
|
+
continue
|
|
511
|
+
report = validate_eval_set(eval_set, call_site)
|
|
512
|
+
if report.errors:
|
|
513
|
+
typer.echo(
|
|
514
|
+
f"skip {call_site.id}: {len(report.errors)} eval errors (run downshift check-evals)"
|
|
515
|
+
)
|
|
516
|
+
continue
|
|
517
|
+
tasks.append((call_site, eval_set))
|
|
518
|
+
return tasks
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _case_mark(row: ResultRow) -> None:
|
|
522
|
+
mark = "E" if row.error else ("." if row.passed else "x")
|
|
523
|
+
typer.echo(mark, nl=False)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _print_run_summary(summaries: list[RunSummary]) -> None:
|
|
527
|
+
table = Table(title="Run results")
|
|
528
|
+
table.add_column("Call site", overflow="fold")
|
|
529
|
+
table.add_column("Model", no_wrap=True)
|
|
530
|
+
table.add_column("Cases", justify="right")
|
|
531
|
+
table.add_column("Passed", justify="right")
|
|
532
|
+
table.add_column("Score", justify="right")
|
|
533
|
+
table.add_column("Latency", justify="right")
|
|
534
|
+
table.add_column("Tokens in/out", justify="right")
|
|
535
|
+
table.add_column("Errors", justify="right")
|
|
536
|
+
for s in summaries:
|
|
537
|
+
rate = f"{s.passed}/{s.scored} ({s.pass_rate:.0%})" if s.pass_rate is not None else "-"
|
|
538
|
+
score = f"{s.mean_score:.2f}" if s.mean_score is not None else "-"
|
|
539
|
+
latency = f"{s.avg_latency_s:.2f}s" if s.avg_latency_s is not None else "-"
|
|
540
|
+
tokens = (
|
|
541
|
+
f"{s.avg_prompt_tokens:.0f}/{s.avg_completion_tokens:.0f}"
|
|
542
|
+
if s.avg_prompt_tokens is not None and s.avg_completion_tokens is not None
|
|
543
|
+
else "-"
|
|
544
|
+
)
|
|
545
|
+
errors = f"[red]{s.errors}[/red]" if s.errors else "0"
|
|
546
|
+
table.add_row(s.site_id, s.model, str(s.cases), rate, score, latency, tokens, errors)
|
|
547
|
+
Console().print(table)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
# --- rescore ------------------------------------------------------------------
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
@app.command()
|
|
554
|
+
def rescore(
|
|
555
|
+
callsites: Path = typer.Option(..., "--callsites", help="Audit (or callsites) JSON."),
|
|
556
|
+
evals: Path | None = typer.Option(
|
|
557
|
+
None, "--evals", help="Eval folder. Default: evals/ next to --callsites."
|
|
558
|
+
),
|
|
559
|
+
results: Path | None = typer.Option(
|
|
560
|
+
None, "--results", help="Results folder. Default: results/ next to --callsites."
|
|
561
|
+
),
|
|
562
|
+
model: list[str] = typer.Option(
|
|
563
|
+
[], "--model", help="Only these models' results. Default: baseline + candidates."
|
|
564
|
+
),
|
|
565
|
+
site: list[str] = typer.Option([], "--site", help="Only this call site id. Repeatable."),
|
|
566
|
+
config: Path | None = typer.Option(None, "--config", "-c", help="Config file."),
|
|
567
|
+
judge_model: str | None = typer.Option(
|
|
568
|
+
None, "--judge-model", help="Judge model. Default: models.judge or baseline."
|
|
569
|
+
),
|
|
570
|
+
judge_base_url: str | None = typer.Option(
|
|
571
|
+
None, "--judge-base-url", help="Separate OpenAI-compatible endpoint for the judge."
|
|
572
|
+
),
|
|
573
|
+
judge_api_key_env: str = typer.Option(
|
|
574
|
+
"JUDGE_API_KEY", "--judge-api-key-env", help="Env var with the judge API key."
|
|
575
|
+
),
|
|
576
|
+
judge_max_tokens: int = typer.Option(
|
|
577
|
+
1024, "--judge-max-tokens", min=16, help="Max tokens per judge reply."
|
|
578
|
+
),
|
|
579
|
+
) -> None:
|
|
580
|
+
"""Re-grade saved outputs of judge-graded call sites with the configured judge."""
|
|
581
|
+
try:
|
|
582
|
+
result = ScanResult.load(callsites)
|
|
583
|
+
cfg = resolve_config(config, callsites.parent)
|
|
584
|
+
client = _make_client(cfg.provider.base_url, cfg.provider.api_key())
|
|
585
|
+
judge = _build_judge(
|
|
586
|
+
client,
|
|
587
|
+
judge_model or cfg.models.judge_model,
|
|
588
|
+
judge_base_url,
|
|
589
|
+
judge_api_key_env,
|
|
590
|
+
judge_max_tokens,
|
|
591
|
+
)
|
|
592
|
+
except (SchemaError, ConfigError, ValueError) as exc:
|
|
593
|
+
_fail(str(exc))
|
|
594
|
+
return
|
|
595
|
+
|
|
596
|
+
evals_dir = evals if evals is not None else callsites.parent / "evals"
|
|
597
|
+
results_dir = results if results is not None else callsites.parent / "results"
|
|
598
|
+
if not evals_dir.is_dir():
|
|
599
|
+
_fail(f"folder not found: {evals_dir}")
|
|
600
|
+
return
|
|
601
|
+
|
|
602
|
+
known = {s.id for s in result.call_sites}
|
|
603
|
+
unknown = [s for s in site if s not in known]
|
|
604
|
+
if unknown:
|
|
605
|
+
_fail(f"unknown call site id(s): {', '.join(unknown)}")
|
|
606
|
+
return
|
|
607
|
+
sites = [s for s in result.call_sites if s.grading == "judge" and (not site or s.id in site)]
|
|
608
|
+
tasks = _load_run_tasks(sites, evals_dir)
|
|
609
|
+
if not tasks:
|
|
610
|
+
_fail("nothing to rescore: no judge-graded call site with a valid eval file")
|
|
611
|
+
return
|
|
612
|
+
|
|
613
|
+
models = model or [cfg.models.baseline, *cfg.models.candidates]
|
|
614
|
+
typer.echo(f"judge: {judge.model}")
|
|
615
|
+
pairs: list[tuple[RunSummary, RunSummary]] = []
|
|
616
|
+
for name in models:
|
|
617
|
+
for call_site, eval_set in tasks:
|
|
618
|
+
if not results_path(results_dir, call_site.id, name).is_file():
|
|
619
|
+
typer.echo(f"skip {name} {call_site.id}: no results (run downshift run first)")
|
|
620
|
+
continue
|
|
621
|
+
typer.echo(f"{name} {call_site.id} ", nl=False)
|
|
622
|
+
before, after = rescore_site(
|
|
623
|
+
call_site,
|
|
624
|
+
eval_set,
|
|
625
|
+
name,
|
|
626
|
+
results_dir=results_dir,
|
|
627
|
+
judge=judge,
|
|
628
|
+
on_case=_case_mark,
|
|
629
|
+
)
|
|
630
|
+
pairs.append((before, after))
|
|
631
|
+
typer.echo(
|
|
632
|
+
f" {before.passed}/{before.scored} -> {after.passed}/{after.scored} passed, "
|
|
633
|
+
f"{after.new} rescored"
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
_print_rescore_summary(pairs, judge.model)
|
|
637
|
+
errors = sum(after.errors for _, after in pairs)
|
|
638
|
+
typer.echo(f"{len(pairs)} site/model results rescored, {errors} errors. Saved in {results_dir}")
|
|
639
|
+
if errors:
|
|
640
|
+
raise typer.Exit(code=1)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _rate(summary: RunSummary) -> str:
|
|
644
|
+
if summary.pass_rate is None:
|
|
645
|
+
return "-"
|
|
646
|
+
return f"{summary.passed}/{summary.scored} ({summary.pass_rate:.0%})"
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _print_rescore_summary(pairs: list[tuple[RunSummary, RunSummary]], judge_model: str) -> None:
|
|
650
|
+
table = Table(title=f"Rescored with {judge_model}")
|
|
651
|
+
table.add_column("Call site", overflow="fold")
|
|
652
|
+
table.add_column("Model", no_wrap=True)
|
|
653
|
+
table.add_column("Before", justify="right")
|
|
654
|
+
table.add_column("After", justify="right")
|
|
655
|
+
table.add_column("Rescored", justify="right")
|
|
656
|
+
table.add_column("Errors", justify="right")
|
|
657
|
+
for before, after in pairs:
|
|
658
|
+
errors = f"[red]{after.errors}[/red]" if after.errors else "0"
|
|
659
|
+
table.add_row(
|
|
660
|
+
after.site_id, after.model, _rate(before), _rate(after), str(after.new), errors
|
|
661
|
+
)
|
|
662
|
+
Console().print(table)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
# --- not implemented yet ------------------------------------------------------
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
@app.command()
|
|
669
|
+
def report(
|
|
670
|
+
callsites: Path = typer.Option(..., "--callsites", help="Audit (or callsites) JSON."),
|
|
671
|
+
evals: Path | None = typer.Option(
|
|
672
|
+
None, "--evals", help="Eval folder. Default: evals/ next to --callsites."
|
|
673
|
+
),
|
|
674
|
+
results: Path | None = typer.Option(
|
|
675
|
+
None, "--results", help="Results folder. Default: results/ next to --callsites."
|
|
676
|
+
),
|
|
677
|
+
config: Path | None = typer.Option(None, "--config", "-c", help="Config file."),
|
|
678
|
+
threshold: float | None = typer.Option(
|
|
679
|
+
None,
|
|
680
|
+
"--threshold",
|
|
681
|
+
min=0.0,
|
|
682
|
+
max=1.0,
|
|
683
|
+
help="Quality threshold override (0,1]. Default: from config.",
|
|
684
|
+
),
|
|
685
|
+
min_pass_rate: float | None = typer.Option(
|
|
686
|
+
None,
|
|
687
|
+
"--min-pass-rate",
|
|
688
|
+
min=0.0,
|
|
689
|
+
max=1.0,
|
|
690
|
+
help="Minimum pass rate override [0,1]. Default: from config.",
|
|
691
|
+
),
|
|
692
|
+
out: Path | None = typer.Option(None, "--out", help="Write Markdown to this file."),
|
|
693
|
+
) -> None:
|
|
694
|
+
"""Render the cost and quality report."""
|
|
695
|
+
if threshold is not None and threshold <= 0:
|
|
696
|
+
_fail("--threshold must be greater than 0")
|
|
697
|
+
return
|
|
698
|
+
try:
|
|
699
|
+
scan = ScanResult.load(callsites)
|
|
700
|
+
cfg = resolve_config(config, callsites.parent)
|
|
701
|
+
except (SchemaError, ConfigError) as exc:
|
|
702
|
+
_fail(str(exc))
|
|
703
|
+
return
|
|
704
|
+
|
|
705
|
+
evals_dir = evals if evals is not None else callsites.parent / "evals"
|
|
706
|
+
results_dir = results if results is not None else callsites.parent / "results"
|
|
707
|
+
|
|
708
|
+
try:
|
|
709
|
+
rep = build_report(
|
|
710
|
+
scan,
|
|
711
|
+
cfg,
|
|
712
|
+
evals_dir,
|
|
713
|
+
results_dir,
|
|
714
|
+
threshold=threshold,
|
|
715
|
+
min_pass_rate=min_pass_rate,
|
|
716
|
+
)
|
|
717
|
+
except ReportError as exc:
|
|
718
|
+
_fail(str(exc))
|
|
719
|
+
return
|
|
720
|
+
|
|
721
|
+
md = render_markdown(rep)
|
|
722
|
+
|
|
723
|
+
if out is None:
|
|
724
|
+
typer.echo(md, nl=False)
|
|
725
|
+
return
|
|
726
|
+
|
|
727
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
728
|
+
out.write_text(md, encoding="utf-8")
|
|
729
|
+
|
|
730
|
+
costs = rep.costs
|
|
731
|
+
n_down = len(rep.downgraded)
|
|
732
|
+
n_total = len(rep.decisions)
|
|
733
|
+
savings = costs.savings if costs.sites else 0.0
|
|
734
|
+
savings_pct = costs.savings_pct
|
|
735
|
+
pct_str = f"{savings_pct:.1%}" if savings_pct is not None else "n/a"
|
|
736
|
+
typer.echo(
|
|
737
|
+
f"Wrote {out}: downgraded {n_down} of {n_total} call sites,"
|
|
738
|
+
f" projected savings ${savings:,.2f}/month ({pct_str})."
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
@app.command()
|
|
743
|
+
def diff() -> None:
|
|
744
|
+
"""Show the projected LLM cost change between two git refs."""
|
|
745
|
+
_not_implemented("diff")
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
@app.command()
|
|
749
|
+
def dashboard() -> None:
|
|
750
|
+
"""Build the static HTML dashboard."""
|
|
751
|
+
_not_implemented("dashboard")
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def main() -> None:
|
|
755
|
+
app()
|