downshift 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.
downshift/cli.py ADDED
@@ -0,0 +1,930 @@
1
+ """Downshift command line interface."""
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from downshift import __version__
13
+ from downshift.audit import Comparison, compare_scans, validate_file
14
+ from downshift.config import Config, ConfigError, resolve_config
15
+ from downshift.diff import DEFAULT_COMPLETION_TOKENS, diff_for_config
16
+ from downshift.diff import render_markdown as render_diff_markdown
17
+ from downshift.evalgen import EvalGenSkip, generate_eval_set, write_eval_set
18
+ from downshift.evals import (
19
+ EVAL_SUFFIX,
20
+ EvalError,
21
+ EvalReport,
22
+ EvalSet,
23
+ check_eval_dir,
24
+ load_eval_set,
25
+ site_placeholders,
26
+ slug_for,
27
+ validate_eval_set,
28
+ )
29
+ from downshift.export import DEFAULT_EXAMPLES, build_export, write_export
30
+ from downshift.gitref import GitError, extract_ref, repo_root
31
+ from downshift.llm import LLMClient, LLMError, OpenAICompatClient
32
+ from downshift.report import ReportError, build_report, render_markdown
33
+ from downshift.runner import ResultRow, Runner, RunSummary, rescore_site, results_path
34
+ from downshift.scanner import scan_path
35
+ from downshift.schema import CallSite, ScanResult, SchemaError
36
+ from downshift.scorer import Judge
37
+
38
+ DEFAULT_OUT = Path(".downshift") / "callsites.json"
39
+
40
+ app = typer.Typer(
41
+ name="downshift",
42
+ help=(
43
+ "Find every LLM call in your repo, prove which ones can use cheaper "
44
+ "models, and show the cost impact of every PR."
45
+ ),
46
+ no_args_is_help=True,
47
+ add_completion=False,
48
+ )
49
+
50
+
51
+ def _version_callback(value: bool) -> None:
52
+ if value:
53
+ typer.echo(f"downshift {__version__}")
54
+ raise typer.Exit()
55
+
56
+
57
+ @app.callback()
58
+ def main_callback(
59
+ version: bool = typer.Option(
60
+ False,
61
+ "--version",
62
+ callback=_version_callback,
63
+ is_eager=True,
64
+ help="Show the version and exit.",
65
+ ),
66
+ ) -> None:
67
+ """Downshift: cut LLM costs per PR."""
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
+ def _sites_at(root: Path, ref: str, rel: Path, dest: Path, cfg: Config) -> list[CallSite]:
743
+ target = extract_ref(root, ref, rel, dest)
744
+ if target is None:
745
+ return []
746
+ return list(scan_path(target, cfg.scan).call_sites)
747
+
748
+
749
+ def _signed_dollars(value: float) -> str:
750
+ if abs(value) < 0.005:
751
+ return "$0.00"
752
+ return f"{'+' if value > 0 else '-'}${abs(value):,.2f}"
753
+
754
+
755
+ @app.command()
756
+ def estimate(
757
+ file: Path = typer.Argument(..., help="Scan or audit JSON to price."),
758
+ base: Path | None = typer.Option(
759
+ None, "--base", help="Scan or audit JSON to compare against, e.g. the ast scan."
760
+ ),
761
+ config: Path | None = typer.Option(
762
+ None, "--config", "-c", help="Config file. Default: downshift.yaml next to FILE."
763
+ ),
764
+ completion_tokens: int = typer.Option(
765
+ DEFAULT_COMPLETION_TOKENS,
766
+ "--completion-tokens",
767
+ min=1,
768
+ help="Output tokens assumed for call sites with no max_tokens.",
769
+ ),
770
+ out: Path | None = typer.Option(None, "--out", "-o", help="Write Markdown to this file."),
771
+ ) -> None:
772
+ """Project monthly LLM cost from a scan or audit file. No evals needed."""
773
+ try:
774
+ cfg = resolve_config(config, file.parent)
775
+ head = ScanResult.load(file)
776
+ before = ScanResult.load(base) if base is not None else None
777
+ except (ConfigError, SchemaError, FileNotFoundError) as exc:
778
+ _fail(str(exc))
779
+ return
780
+
781
+ head_label = head.generated_by
782
+ base_label = before.generated_by if before is not None else "no call sites"
783
+ if before is not None and base_label == head_label:
784
+ base_label, head_label = "before", "after"
785
+
786
+ base_sites = list(before.call_sites) if before is not None else []
787
+ cost = diff_for_config(
788
+ base_sites, list(head.call_sites), cfg, default_completion=completion_tokens
789
+ )
790
+ md = render_diff_markdown(cost, base=base_label, head=head_label)
791
+ if out is None:
792
+ typer.echo(md, nl=False)
793
+ return
794
+ out.parent.mkdir(parents=True, exist_ok=True)
795
+ out.write_text(md)
796
+ typer.echo(f"Wrote {out}")
797
+
798
+
799
+ @app.command()
800
+ def diff(
801
+ path: Path = typer.Argument(Path("."), help="Directory to scan, inside a git repo."),
802
+ base: str = typer.Option("main", "--base", help="Git ref to compare against."),
803
+ head: str | None = typer.Option(
804
+ None, "--head", help="Git ref to compare. Default: the working tree."
805
+ ),
806
+ config: Path | None = typer.Option(
807
+ None,
808
+ "--config",
809
+ "-c",
810
+ help="Config file (used for both sides). Default: downshift.yaml in PATH.",
811
+ ),
812
+ out: Path | None = typer.Option(None, "--out", "-o", help="Write Markdown to this file."),
813
+ fail_above: float | None = typer.Option(
814
+ None,
815
+ "--fail-above",
816
+ min=0.0,
817
+ help="Exit 1 if the projected monthly increase is above this many dollars.",
818
+ ),
819
+ ) -> None:
820
+ """Show the projected LLM cost change between two git refs."""
821
+ try:
822
+ cfg = resolve_config(config, path)
823
+ root = repo_root(path)
824
+ rel = path.resolve().relative_to(root)
825
+ with tempfile.TemporaryDirectory() as tmp:
826
+ base_sites = _sites_at(root, base, rel, Path(tmp) / "base", cfg)
827
+ if head is None:
828
+ head_sites = list(scan_path(path, cfg.scan).call_sites)
829
+ else:
830
+ head_sites = _sites_at(root, head, rel, Path(tmp) / "head", cfg)
831
+ except (ConfigError, GitError, FileNotFoundError, ValueError) as exc:
832
+ _fail(str(exc))
833
+ return
834
+
835
+ cost = diff_for_config(base_sites, head_sites, cfg)
836
+ md = render_diff_markdown(cost, base=base, head=head or "working tree")
837
+ delta = _signed_dollars(cost.delta)
838
+
839
+ if out is None:
840
+ typer.echo(md, nl=False)
841
+ else:
842
+ out.parent.mkdir(parents=True, exist_ok=True)
843
+ out.write_text(md, encoding="utf-8")
844
+ n = len(cost.added) + len(cost.changed) + len(cost.removed)
845
+ typer.echo(f"Wrote {out}: {n} call site change(s), projected {delta}/month.")
846
+
847
+ if fail_above is not None and cost.delta > fail_above:
848
+ typer.echo(
849
+ f"Projected increase {delta}/month is above --fail-above ${fail_above:,.2f}.",
850
+ err=True,
851
+ )
852
+ raise typer.Exit(code=1)
853
+
854
+
855
+ @app.command()
856
+ def export(
857
+ path: Path = typer.Argument(
858
+ Path("."), help="Folder with downshift.audit.json, evals/ and results/."
859
+ ),
860
+ out: Path = typer.Option(Path("downshift-export"), "--out", "-o", help="Output folder."),
861
+ callsites: Path | None = typer.Option(
862
+ None, "--callsites", help="Audit JSON. Default: PATH/downshift.audit.json."
863
+ ),
864
+ ast_file: Path | None = typer.Option(
865
+ None, "--ast", help="Ast scan JSON. Default: PATH/downshift.scan.json if present."
866
+ ),
867
+ ast_after_file: Path | None = typer.Option(
868
+ None,
869
+ "--ast-after",
870
+ help="Ast scan after refactor. Default: PATH/downshift.scan.after.json if present.",
871
+ ),
872
+ config: Path | None = typer.Option(None, "--config", "-c", help="Config file."),
873
+ examples: int = typer.Option(
874
+ DEFAULT_EXAMPLES, "--examples", min=0, help="Example eval cases per call site."
875
+ ),
876
+ name: str | None = typer.Option(None, "--name", help="Project name. Default: PATH name."),
877
+ ) -> None:
878
+ """Export JSON and the report for the demo web app."""
879
+ audit_path = callsites if callsites is not None else path / "downshift.audit.json"
880
+ ast_path = ast_file if ast_file is not None else path / "downshift.scan.json"
881
+ after_path = (
882
+ ast_after_file if ast_after_file is not None else path / "downshift.scan.after.json"
883
+ )
884
+ for required, explicit in (
885
+ (audit_path, True),
886
+ (ast_path, ast_file is not None),
887
+ (after_path, ast_after_file is not None),
888
+ ):
889
+ if explicit and not required.exists():
890
+ _fail(f"File not found: {required}")
891
+ return
892
+
893
+ try:
894
+ audit = ScanResult.load(audit_path)
895
+ ast_scan = ScanResult.load(ast_path) if ast_path.exists() else None
896
+ after_scan = ScanResult.load(after_path) if after_path.exists() else None
897
+ cfg = resolve_config(config, audit_path.parent)
898
+ except (SchemaError, ConfigError) as exc:
899
+ _fail(str(exc))
900
+ return
901
+
902
+ evals_dir = audit_path.parent / "evals"
903
+ results_dir = audit_path.parent / "results"
904
+ try:
905
+ rep = build_report(audit, cfg, evals_dir, results_dir)
906
+ except ReportError as exc:
907
+ _fail(str(exc))
908
+ return
909
+
910
+ payloads = build_export(
911
+ rep,
912
+ cfg,
913
+ audit=audit,
914
+ evals_dir=evals_dir,
915
+ results_dir=results_dir,
916
+ ast=ast_scan,
917
+ ast_after=after_scan,
918
+ project=name or path.resolve().name,
919
+ examples=examples,
920
+ )
921
+ written = write_export(out, payloads, render_markdown(rep))
922
+ savings = rep.costs.savings if rep.costs.sites else 0.0
923
+ typer.echo(
924
+ f"Wrote {len(written)} files to {out}: downgraded {len(rep.downgraded)} of"
925
+ f" {len(rep.decisions)} call sites, projected savings ${savings:,.2f}/month."
926
+ )
927
+
928
+
929
+ def main() -> None:
930
+ app()