benchmark-router-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.
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.5
2
+ Name: benchmark-router-cli
3
+ Version: 0.1.0
4
+ Summary: Command-line client for Benchmark Router
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: httpx>=0.27
7
+ Requires-Dist: pyyaml>=6
8
+ Requires-Dist: rich>=13
9
+ Requires-Dist: typer>=0.12
@@ -0,0 +1,8 @@
1
+ brouter_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ brouter_cli/client.py,sha256=DDKCEPha2wKdtHO6NOAZc_yAQvYpAWTXUiH4bX24omg,3699
3
+ brouter_cli/config.py,sha256=IdrZ38hLFYVFF1OvDAORCsyFBxEquENXmFbXX7kdr9o,1345
4
+ brouter_cli/main.py,sha256=9QDtp8IEalr93dcidZeQN0T2LlXWBV5UIIrxBlF81ho,20931
5
+ benchmark_router_cli-0.1.0.dist-info/METADATA,sha256=BHwZqS7hywoahA3N2NhZhdWskM7747jxkEawlJFYxrc,241
6
+ benchmark_router_cli-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
7
+ benchmark_router_cli-0.1.0.dist-info/entry_points.txt,sha256=ZcKWWoqvGW9LkeRGfgNTL0QKkKeeYOrWni3XehA7BQU,49
8
+ benchmark_router_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ brouter = brouter_cli.main:app
File without changes
brouter_cli/client.py ADDED
@@ -0,0 +1,95 @@
1
+ from typing import Any
2
+
3
+ import httpx
4
+
5
+ from .config import Settings
6
+
7
+
8
+ class ApiError(Exception):
9
+ def __init__(self, status: int, message: str):
10
+ super().__init__(f"{status}: {message}")
11
+ self.status = status
12
+ self.message = message
13
+
14
+
15
+ class Client:
16
+ def __init__(self, settings: Settings, transport: httpx.BaseTransport | None = None):
17
+ if not settings.api_key:
18
+ raise ApiError(401, "no API key; run `brouter login` or set BROUTER_API_KEY")
19
+ self.api_url = settings.api_url
20
+ self._http = httpx.Client(
21
+ base_url=f"{settings.api_url}/api/v1",
22
+ headers={"Authorization": f"Bearer {settings.api_key}"},
23
+ timeout=60,
24
+ transport=transport,
25
+ )
26
+
27
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
28
+ resp = self._http.request(method, path, **kwargs)
29
+ if resp.status_code >= 400:
30
+ try:
31
+ body = resp.json()
32
+ message = body.get("error") or body.get("message") or resp.text
33
+ except ValueError:
34
+ message = resp.text
35
+ raise ApiError(resp.status_code, str(message))
36
+ return resp.json()
37
+
38
+ def benchmarks(self) -> list[dict]:
39
+ return self._request("GET", "/benchmarks")["benchmarks"]
40
+
41
+ def models(self) -> list[dict]:
42
+ return self._request("GET", "/models")["models"]
43
+
44
+ def estimate(self, body: dict) -> dict:
45
+ return self._request("POST", "/estimate", json=body)
46
+
47
+ def create_run(self, body: dict) -> dict:
48
+ return self._request("POST", "/runs", json=body)["run"]
49
+
50
+ def runs(self) -> list[dict]:
51
+ return self._request("GET", "/runs")["runs"]
52
+
53
+ def run(self, run_id: str) -> dict:
54
+ return self._request("GET", f"/runs/{run_id}")["run"]
55
+
56
+ def download(self, run_id: str) -> list[dict]:
57
+ return self._request("GET", f"/runs/{run_id}/download")["files"]
58
+
59
+ def balance(self) -> dict:
60
+ return self._request("GET", "/balance")
61
+
62
+ def task(self, run_id: str) -> dict:
63
+ return self._request("GET", f"/runs/{run_id}/task")
64
+
65
+ def comparisons(self) -> list[dict]:
66
+ return self._request("GET", "/comparisons")["comparisons"]
67
+
68
+ def estimate_comparison(self, harness_id: str, model_ids: list[str]) -> dict:
69
+ return self._request("POST", "/comparisons", json={"harness_id": harness_id, "model_ids": model_ids, "dry_run": True})
70
+
71
+ def create_comparison(self, harness_id: str, model_ids: list[str]) -> dict:
72
+ # The server submits one run per benchmark x entrant, so this can take minutes.
73
+ body = {"harness_id": harness_id, "model_ids": model_ids}
74
+ return self._request("POST", "/comparisons", json=body, timeout=300)["comparison"]
75
+
76
+ def update_comparison(self, comparison_id: str, body: dict) -> dict:
77
+ return self._request("PATCH", f"/comparisons/{comparison_id}", json=body)["comparison"]
78
+
79
+ def harness_requests(self) -> list[dict]:
80
+ return self._request("GET", "/harness-requests")["requests"]
81
+
82
+ def create_harness_request(self, body: dict) -> dict:
83
+ return self._request("POST", "/harness-requests", json=body)["request"]
84
+
85
+ def benchmark_requests(self) -> list[dict]:
86
+ return self._request("GET", "/benchmark-requests")["requests"]
87
+
88
+ def create_benchmark_request(self, body: dict) -> dict:
89
+ return self._request("POST", "/benchmark-requests", json=body)["request"]
90
+
91
+ def checkout(self, amount: float) -> str:
92
+ return self._request("POST", "/credits/checkout", json={"credits": amount})["url"]
93
+
94
+ def fetch(self, url: str) -> httpx.Response:
95
+ return httpx.get(url, timeout=600, follow_redirects=True)
brouter_cli/config.py ADDED
@@ -0,0 +1,50 @@
1
+ import json
2
+ import os
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ DEFAULT_API_URL = "https://benchmarkrouter.newmeasure.ai"
7
+
8
+
9
+ def _config_dir() -> Path:
10
+ return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
11
+
12
+
13
+ def config_path() -> Path:
14
+ return _config_dir() / "brouter" / "config.json"
15
+
16
+
17
+ def _env(name: str) -> str | None:
18
+ # EVALHUB_* is the pre-rename spelling, still honoured.
19
+ return os.environ.get(f"BROUTER_{name}") or os.environ.get(f"EVALHUB_{name}")
20
+
21
+
22
+ @dataclass
23
+ class Settings:
24
+ api_url: str
25
+ api_key: str | None
26
+
27
+
28
+ def load_settings() -> Settings:
29
+ stored: dict = {}
30
+ path = config_path()
31
+ legacy = _config_dir() / "evalh" / "config.json"
32
+ if not path.exists() and legacy.exists():
33
+ path = legacy
34
+ if path.exists():
35
+ stored = json.loads(path.read_text())
36
+ return Settings(
37
+ api_url=(_env("API_URL") or stored.get("api_url") or DEFAULT_API_URL).rstrip("/"),
38
+ api_key=_env("API_KEY") or stored.get("api_key"),
39
+ )
40
+
41
+
42
+ def save_settings(api_key: str, api_url: str | None) -> Path:
43
+ path = config_path()
44
+ path.parent.mkdir(parents=True, exist_ok=True)
45
+ data = {"api_key": api_key}
46
+ if api_url:
47
+ data["api_url"] = api_url.rstrip("/")
48
+ path.write_text(json.dumps(data, indent=2))
49
+ path.chmod(0o600)
50
+ return path
brouter_cli/main.py ADDED
@@ -0,0 +1,523 @@
1
+ import json
2
+ import time
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import typer
7
+ import yaml
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from .client import ApiError, Client
12
+ from .config import load_settings, save_settings
13
+
14
+ app = typer.Typer(help="Run public LLM benchmarks on Benchmark Router.", no_args_is_help=True)
15
+ console = Console()
16
+
17
+ TERMINAL = {"succeeded", "failed"}
18
+
19
+
20
+ def credits(amount: Optional[float]) -> str:
21
+ """The API reports amounts as decimal credits (1 credit = $1)."""
22
+ return "-" if amount is None else f"{amount:,.4f} credits"
23
+
24
+
25
+ def client() -> Client:
26
+ try:
27
+ return Client(load_settings())
28
+ except ApiError as e:
29
+ console.print(f"[red]{e.message}[/red]")
30
+ raise typer.Exit(1)
31
+
32
+
33
+ def parse_option(raw: str) -> tuple[str, object]:
34
+ if "=" not in raw:
35
+ raise typer.BadParameter(f"expected key=value, got {raw!r}")
36
+ key, value = raw.split("=", 1)
37
+ return key.strip(), yaml.safe_load(value)
38
+
39
+
40
+ def build_request(
41
+ benchmark: Optional[str],
42
+ model: Optional[str],
43
+ reasoning: Optional[str],
44
+ option: list[str],
45
+ subset: list[str],
46
+ config: Optional[Path],
47
+ harness: Optional[str] = None,
48
+ ) -> dict:
49
+ body: dict = {}
50
+ if config:
51
+ body = yaml.safe_load(config.read_text()) or {}
52
+ if "benchmark" in body:
53
+ body["benchmark_id"] = body.pop("benchmark")
54
+ if "model" in body:
55
+ body["model_id"] = body.pop("model")
56
+ if benchmark:
57
+ body["benchmark_id"] = benchmark
58
+ if model:
59
+ body["model_id"] = model
60
+ if reasoning:
61
+ body["reasoning"] = reasoning
62
+ if option:
63
+ body["options"] = {**body.get("options", {}), **dict(parse_option(o) for o in option)}
64
+ if harness:
65
+ body["options"] = {**body.get("options", {}), "harness": harness}
66
+ if subset:
67
+ body["subsets"] = subset
68
+ for required in ("benchmark_id", "model_id"):
69
+ if not body.get(required):
70
+ raise typer.BadParameter(f"missing {required.removesuffix('_id')} (argument, --model, or config file)")
71
+ return body
72
+
73
+
74
+ def call(fn, *args):
75
+ try:
76
+ return fn(*args)
77
+ except ApiError as e:
78
+ console.print(f"[red]Error {e.status}:[/red] {e.message}")
79
+ raise typer.Exit(1)
80
+
81
+
82
+ RunArgs = dict(
83
+ benchmark=typer.Argument(None, help="Benchmark id, e.g. hle"),
84
+ model=typer.Option(None, "--model", "-m", help="Model id, e.g. anthropic/claude-opus-5-5"),
85
+ reasoning=typer.Option(None, "--reasoning", "-r", help="Native reasoning level for the model"),
86
+ option=typer.Option([], "--option", "-o", help="Benchmark option as key=value (repeatable)"),
87
+ subset=typer.Option([], "--subset", "-s", help="Benchmark-defined subset id (repeatable); default all"),
88
+ config=typer.Option(None, "--config", "-c", exists=True, help="YAML file with benchmark/model/reasoning/options/subsets"),
89
+ harness=typer.Option(
90
+ None, "--harness", "-H", help="Agent harness (see `brouter harnesses BENCHMARK`); default: the benchmark's pinned one"
91
+ ),
92
+ )
93
+
94
+
95
+ @app.command()
96
+ def login(
97
+ api_key: str = typer.Option(..., prompt=True, hide_input=True, help="API key from the web app"),
98
+ api_url: Optional[str] = typer.Option(None, help="Override API base URL"),
99
+ ):
100
+ """Save an API key."""
101
+ path = save_settings(api_key, api_url)
102
+ console.print(f"Saved to {path}")
103
+
104
+
105
+ @app.command()
106
+ def benchmarks(as_json: bool = typer.Option(False, "--json")):
107
+ """List available benchmarks."""
108
+ items = call(client().benchmarks)
109
+ if as_json:
110
+ print(json.dumps(items, indent=2))
111
+ return
112
+ table = Table("id", "name", "version", "subsets")
113
+ for b in items:
114
+ table.add_row(b["id"], b["name"], b["version"], ", ".join(s["id"] for s in b["subsets"]))
115
+ console.print(table)
116
+
117
+
118
+ def harness_choices(b: dict) -> Optional[tuple[str, list[tuple[str, str]]]]:
119
+ """(default label, [(id, label)]) from a benchmark's `harness` option, or None if it has none."""
120
+ prop = ((b.get("options_schema") or {}).get("properties") or {}).get("harness")
121
+ if not prop:
122
+ return None
123
+ choices = [(str(c["const"]), c.get("title") or str(c["const"])) for c in prop.get("oneOf") or [] if "const" in c]
124
+ return prop.get("x-unset-label") or "benchmark default", choices
125
+
126
+
127
+ @app.command()
128
+ def harnesses(benchmark: str = typer.Argument(..., help="Benchmark id"), as_json: bool = typer.Option(False, "--json")):
129
+ """List the agent harnesses a benchmark can run (pass one with `--harness`)."""
130
+ items = call(client().benchmarks)
131
+ b = next((x for x in items if x["id"] == benchmark), None)
132
+ if b is None:
133
+ console.print(f"[red]Unknown benchmark {benchmark!r}[/red]")
134
+ raise typer.Exit(1)
135
+ found = harness_choices(b)
136
+ if as_json:
137
+ print(json.dumps({"default": found[0] if found else None, "harnesses": [{"id": i, "label": l} for i, l in (found[1] if found else [])]}, indent=2))
138
+ return
139
+ if not found:
140
+ console.print(f"{benchmark} runs its own harness; it has no harness choice.")
141
+ return
142
+ default, choices = found
143
+ console.print(f"Default (no --harness): {default}")
144
+ table = Table("id", "harness")
145
+ for i, label in choices:
146
+ table.add_row(i, label)
147
+ console.print(table)
148
+
149
+
150
+ @app.command()
151
+ def models(as_json: bool = typer.Option(False, "--json")):
152
+ """List available models and their reasoning levels."""
153
+ items = call(client().models)
154
+ if as_json:
155
+ print(json.dumps(items, indent=2))
156
+ return
157
+ table = Table("id", "name", "reasoning levels", "default")
158
+ for m in items:
159
+ table.add_row(m["id"], m["display_name"], ", ".join(m.get("reasoning_levels") or []) or "-", m.get("default_reasoning") or "-")
160
+ console.print(table)
161
+
162
+
163
+ @app.command()
164
+ def estimate(
165
+ benchmark: Optional[str] = RunArgs["benchmark"],
166
+ model: Optional[str] = RunArgs["model"],
167
+ reasoning: Optional[str] = RunArgs["reasoning"],
168
+ option: list[str] = RunArgs["option"],
169
+ subset: list[str] = RunArgs["subset"],
170
+ config: Optional[Path] = RunArgs["config"],
171
+ harness: Optional[str] = RunArgs["harness"],
172
+ ):
173
+ """Show the price of a run before submitting it."""
174
+ body = build_request(benchmark, model, reasoning, option, subset, config, harness)
175
+ est = call(client().estimate, body)
176
+ table = Table("subset", "price")
177
+ for u in est["units"]:
178
+ table.add_row(u["subset"], credits(u["price_credits"]))
179
+ console.print(table)
180
+ console.print(f"Estimated price: [bold]{credits(est['estimate_credits'])}[/bold]")
181
+
182
+
183
+ @app.command()
184
+ def run(
185
+ benchmark: Optional[str] = RunArgs["benchmark"],
186
+ model: Optional[str] = RunArgs["model"],
187
+ reasoning: Optional[str] = RunArgs["reasoning"],
188
+ option: list[str] = RunArgs["option"],
189
+ subset: list[str] = RunArgs["subset"],
190
+ config: Optional[Path] = RunArgs["config"],
191
+ harness: Optional[str] = RunArgs["harness"],
192
+ wait: bool = typer.Option(False, "--wait", "-w", help="Wait for the run to finish"),
193
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt"),
194
+ ):
195
+ """Submit a run."""
196
+ body = build_request(benchmark, model, reasoning, option, subset, config, harness)
197
+ c = client()
198
+ est = call(c.estimate, body)
199
+ if not yes:
200
+ typer.confirm(f"Estimated price {credits(est['estimate_credits'])}. Submit?", abort=True)
201
+ r = call(c.create_run, body)
202
+ console.print(f"Run [bold]{r['id']}[/bold] {r['status']}")
203
+ if wait:
204
+ r = wait_for(c, r["id"])
205
+ show_run(r)
206
+
207
+
208
+ def wait_for(c: Client, run_id: str, interval: float = 15) -> dict:
209
+ with console.status("Waiting for run…"):
210
+ while True:
211
+ r = call(c.run, run_id)
212
+ if r["status"] in TERMINAL:
213
+ return r
214
+ time.sleep(interval)
215
+
216
+
217
+ def show_run(r: dict) -> None:
218
+ console.print(f"[bold]{r['id']}[/bold] {r['benchmark_id']} · {r['model_id']}" + (f" @{r['reasoning']}" if r.get("reasoning") else ""))
219
+ console.print(f"status: {r['status']} estimate: {credits(r.get('estimate_credits'))} charged: {credits(r.get('charge_credits'))}")
220
+ if r.get("subsets"):
221
+ console.print(f"subsets: {', '.join(r['subsets'])}")
222
+ if r.get("error"):
223
+ console.print(f"[red]error:[/red] {r['error']}")
224
+ if r.get("metrics"):
225
+ console.print_json(json.dumps(r["metrics"]))
226
+
227
+
228
+ @app.command()
229
+ def runs():
230
+ """List your runs."""
231
+ items = call(client().runs)
232
+ table = Table("id", "benchmark", "model", "status", "charged", "created")
233
+ for r in items:
234
+ table.add_row(r["id"], r["benchmark_id"], r["model_id"], r["status"], credits(r.get("charge_credits")), r["created_at"])
235
+ console.print(table)
236
+
237
+
238
+ @app.command()
239
+ def status(run_id: str, wait: bool = typer.Option(False, "--wait", "-w")):
240
+ """Show a run."""
241
+ c = client()
242
+ r = wait_for(c, run_id) if wait else call(c.run, run_id)
243
+ show_run(r)
244
+
245
+
246
+ @app.command()
247
+ def download(run_id: str, dest: Path = typer.Option(Path("."), "--dest", "-d", help="Output directory")):
248
+ """Download a run's results (results.json) and logs (<subset>/logs.tar.gz)."""
249
+ c = client()
250
+ files = call(c.download, run_id)
251
+ root = dest / run_id
252
+ for f in files:
253
+ target = root / f["subset"] / f["name"]
254
+ target.parent.mkdir(parents=True, exist_ok=True)
255
+ resp = c.fetch(f["url"])
256
+ resp.raise_for_status()
257
+ target.write_bytes(resp.content)
258
+ console.print(f" {target}")
259
+ console.print(f"Downloaded {len(files)} files to {root}")
260
+
261
+
262
+ @app.command()
263
+ def balance(ledger: bool = typer.Option(False, "--ledger", help="Show ledger entries")):
264
+ """Show your credit balance."""
265
+ b = call(client().balance)
266
+ console.print(f"Balance: [bold]{credits(b['balance_credits'])}[/bold]")
267
+ if b.get("reserved_credits"):
268
+ console.print(f"Reserved by pending runs: {credits(b['reserved_credits'])}")
269
+ console.print(f"Available for new runs: [bold]{credits(b.get('available_credits'))}[/bold]")
270
+ if ledger:
271
+ table = Table("date", "kind", "amount", "ref")
272
+ for e in b["ledger"]:
273
+ table.add_row(e["created_at"], e["kind"], credits(e["amount_credits"]), e["ref"])
274
+ console.print(table)
275
+
276
+
277
+ # ---------------------------------------------------------------------------- task view
278
+
279
+
280
+ def render_blocks(blocks: list[dict]) -> None:
281
+ for b in blocks:
282
+ kind = b.get("kind")
283
+ cut = " [dim](truncated; full text in the run's logs)[/dim]" if b.get("truncated") else ""
284
+ if kind == "text":
285
+ console.print(b["text"], markup=False, highlight=False)
286
+ elif kind == "thinking":
287
+ console.print("[dim italic]thinking:[/dim italic]")
288
+ console.print(b["text"], style="dim", markup=False, highlight=False)
289
+ elif kind == "tool_call":
290
+ console.print(f"[cyan]→ tool call[/cyan] [bold]{b['name']}[/bold]")
291
+ console.print(b["input"], markup=False, highlight=False)
292
+ elif kind == "tool_result":
293
+ label = "[red]← tool error[/red]" if b.get("error") else "[green]← tool result[/green]"
294
+ console.print(label + (f" [bold]{b['name']}[/bold]" if b.get("name") else ""))
295
+ console.print(b["text"], markup=False, highlight=False)
296
+ elif kind == "image":
297
+ console.print(f"[dim][image: {b['note']}][/dim]")
298
+ elif kind == "omitted":
299
+ console.print(f"[dim]… {b['count']} earlier messages omitted[/dim]")
300
+ if cut:
301
+ console.print(cut)
302
+
303
+
304
+ def render_messages(messages: list[dict]) -> None:
305
+ for m in messages:
306
+ console.rule(f"[bold]{m['role']}[/bold]", align="left", style="dim")
307
+ render_blocks(m["blocks"])
308
+
309
+
310
+ def render_step(s: dict) -> None:
311
+ head = f"#{s['index']} {s['role']} · {s['model']} · {s['input_tokens']:,} in / {s['output_tokens']:,} out"
312
+ if s["status"] != "success":
313
+ head += " · [red]failed[/red]"
314
+ if s.get("new_conversation"):
315
+ head += " · new conversation"
316
+ console.rule(head, align="left")
317
+ render_messages(s.get("inputs") or [])
318
+ if s.get("output"):
319
+ render_messages([s["output"]])
320
+ if s.get("error"):
321
+ console.print(f"[red]error:[/red] {s['error']}")
322
+
323
+
324
+ @app.command()
325
+ def task(run_id: str, as_json: bool = typer.Option(False, "--json", help="Print the raw task view")):
326
+ """Show a single-task run's prompt, transcript and grading."""
327
+ v = call(client().task, run_id)
328
+ if as_json:
329
+ print(json.dumps(v, indent=2))
330
+ return
331
+ console.print(f"[bold]subset:[/bold] {v['subset'] or '-'} [bold]status:[/bold] {v['unit_status']}")
332
+ if v.get("unit_error"):
333
+ console.print(f"[red]error:[/red] {v['unit_error']}")
334
+ console.rule("[bold]Task[/bold]")
335
+ render_messages(v.get("task") or [])
336
+ if v.get("steps"):
337
+ console.rule("[bold]Transcript[/bold]")
338
+ for s in v["steps"]:
339
+ render_step(s)
340
+ if v.get("calls_truncated"):
341
+ console.print("[yellow]Only the first calls are shown; the rest are in the run's logs.[/yellow]")
342
+ if v.get("judge"):
343
+ console.rule("[bold]Judge[/bold]")
344
+ for s in v["judge"]:
345
+ render_step(s)
346
+ if v.get("sample"):
347
+ console.rule("[bold]Grading[/bold]")
348
+ console.print_json(json.dumps(v["sample"]))
349
+
350
+
351
+ # ---------------------------------------------------------------------------- comparisons
352
+
353
+ comparisons_app = typer.Typer(help="Compare your harness against models.", no_args_is_help=True)
354
+ app.add_typer(comparisons_app, name="comparisons")
355
+
356
+ ModelsOpt = typer.Option(..., "--model", "-m", help="Model to compare against (repeatable)")
357
+
358
+
359
+ def public_url(c: Client, comp: dict) -> Optional[str]:
360
+ if not comp.get("published_at") or not comp.get("public_slug"):
361
+ return None
362
+ return f"{c.api_url}/r/{comp['public_slug']}"
363
+
364
+
365
+ @comparisons_app.command("list")
366
+ def comparisons_list(as_json: bool = typer.Option(False, "--json")):
367
+ """List your comparisons."""
368
+ c = client()
369
+ items = call(c.comparisons)
370
+ if as_json:
371
+ print(json.dumps(items, indent=2))
372
+ return
373
+ table = Table("id", "title", "benchmarks", "models", "public", "created")
374
+ for x in items:
375
+ public = public_url(c, x) or "-"
376
+ if x.get("published_at") and x.get("custom_domain"):
377
+ public += f"\nhttps://{x['custom_domain']}"
378
+ table.add_row(x["id"], x["title"], ", ".join(x["benchmark_ids"]), ", ".join(x["model_ids"]), public, x["created_at"])
379
+ console.print(table)
380
+
381
+
382
+ @comparisons_app.command("estimate")
383
+ def comparisons_estimate(harness_id: str = typer.Argument(..., help="Harness id (see `brouter harness list`)"), model: list[str] = ModelsOpt):
384
+ """Show how many runs a comparison starts and its estimated price."""
385
+ est = call(client().estimate_comparison, harness_id, model)
386
+ console.print(f"{est['runs']} runs, estimated price [bold]{credits(est['estimate_credits'])}[/bold]")
387
+
388
+
389
+ @comparisons_app.command("create")
390
+ def comparisons_create(
391
+ harness_id: str = typer.Argument(..., help="Harness id (see `brouter harness list`)"),
392
+ model: list[str] = ModelsOpt,
393
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt"),
394
+ ):
395
+ """Start a comparison: one run per benchmark for your harness and each model."""
396
+ c = client()
397
+ est = call(c.estimate_comparison, harness_id, model)
398
+ if not yes:
399
+ typer.confirm(f"Start {est['runs']} runs, estimated at {credits(est['estimate_credits'])}?", abort=True)
400
+ with console.status("Submitting runs…"):
401
+ comp = call(c.create_comparison, harness_id, model)
402
+ console.print(f"Comparison [bold]{comp['id']}[/bold] started")
403
+
404
+
405
+ @comparisons_app.command("publish")
406
+ def comparisons_publish(comparison_id: str):
407
+ """Publish a comparison as a public report anyone with the link can view."""
408
+ c = client()
409
+ comp = call(c.update_comparison, comparison_id, {"published": True})
410
+ console.print(f"Published: {public_url(c, comp)}")
411
+ if comp.get("custom_domain"):
412
+ console.print(f"Also at: https://{comp['custom_domain']}")
413
+
414
+
415
+ @comparisons_app.command("unpublish")
416
+ def comparisons_unpublish(comparison_id: str):
417
+ """Take a comparison's public report down. Publishing again reuses the same link."""
418
+ call(client().update_comparison, comparison_id, {"published": False})
419
+ console.print("Unpublished")
420
+
421
+
422
+ @comparisons_app.command("domain")
423
+ def comparisons_domain(
424
+ comparison_id: str,
425
+ domain: Optional[str] = typer.Argument(None, help="e.g. benchmarks.yourcompany.com"),
426
+ clear: bool = typer.Option(False, "--clear", help="Remove the custom domain"),
427
+ ):
428
+ """Serve a published comparison's report on your own domain."""
429
+ if bool(domain) == clear:
430
+ raise typer.BadParameter("give a domain or --clear")
431
+ comp = call(client().update_comparison, comparison_id, {"custom_domain": None if clear else domain})
432
+ console.print(f"Custom domain: {comp['custom_domain']}" if comp.get("custom_domain") else "Custom domain removed")
433
+
434
+
435
+ # ---------------------------------------------------------------------------- requests
436
+
437
+ ACCESS = ("api", "cli", "app")
438
+
439
+
440
+ def read_text(value: Optional[str], path: Optional[Path], name: str) -> Optional[str]:
441
+ if value and path:
442
+ raise typer.BadParameter(f"give --{name} or --{name}-file, not both")
443
+ return path.read_text() if path else value
444
+
445
+
446
+ harness_app = typer.Typer(help="Bring your own harness: request an adapter and track it.", no_args_is_help=True)
447
+ app.add_typer(harness_app, name="harness")
448
+
449
+
450
+ @harness_app.command("list")
451
+ def harness_list(as_json: bool = typer.Option(False, "--json")):
452
+ """List your harness requests and their status."""
453
+ items = call(client().harness_requests)
454
+ if as_json:
455
+ print(json.dumps(items, indent=2))
456
+ return
457
+ table = Table("id", "name", "access", "benchmarks", "status", "created")
458
+ for r in items:
459
+ table.add_row(r["id"], r["name"], r["access"], ", ".join(r["benchmark_ids"]), r["status"], r["created_at"])
460
+ console.print(table)
461
+
462
+
463
+ @harness_app.command("request")
464
+ def harness_request(
465
+ name: str = typer.Option(..., "--name", help="Your harness's name"),
466
+ access: str = typer.Option(..., "--access", help="How we reach it: api, cli or app"),
467
+ benchmark: list[str] = typer.Option(..., "--benchmark", "-b", help="Benchmark to run it on (repeatable)"),
468
+ details: Optional[str] = typer.Option(None, "--details", help="How to call it: endpoint/auth, install command, etc."),
469
+ details_file: Optional[Path] = typer.Option(None, "--details-file", exists=True, help="Read --details from a file"),
470
+ docs_url: Optional[str] = typer.Option(None, "--docs-url", help="Link to its docs"),
471
+ ):
472
+ """Request an adapter so your harness can run benchmarks."""
473
+ if access not in ACCESS:
474
+ raise typer.BadParameter("access must be api, cli or app")
475
+ text = read_text(details, details_file, "details")
476
+ if not text:
477
+ raise typer.BadParameter("--details or --details-file is required")
478
+ body = {"name": name, "access": access, "benchmark_ids": benchmark, "details": text, "docs_url": docs_url}
479
+ r = call(client().create_harness_request, body)
480
+ console.print(f"Harness request [bold]{r['id']}[/bold] {r['status']}")
481
+
482
+
483
+ benchmark_request_app = typer.Typer(help="Ask for a benchmark that isn't on Benchmark Router yet.", no_args_is_help=True)
484
+ app.add_typer(benchmark_request_app, name="benchmark-request")
485
+
486
+
487
+ @benchmark_request_app.command("list")
488
+ def benchmark_request_list(as_json: bool = typer.Option(False, "--json")):
489
+ """List your benchmark requests and their status."""
490
+ items = call(client().benchmark_requests)
491
+ if as_json:
492
+ print(json.dumps(items, indent=2))
493
+ return
494
+ table = Table("id", "name", "url", "status", "created")
495
+ for r in items:
496
+ table.add_row(r["id"], r["name"], r.get("url") or "-", r["status"], r["created_at"])
497
+ console.print(table)
498
+
499
+
500
+ @benchmark_request_app.command("create")
501
+ def benchmark_request_create(
502
+ name: str = typer.Argument(..., help="Benchmark name"),
503
+ url: Optional[str] = typer.Option(None, "--url", help="Paper, repo or leaderboard link"),
504
+ notes: Optional[str] = typer.Option(None, "--notes", help="Anything else we should know"),
505
+ ):
506
+ """Request a benchmark."""
507
+ r = call(client().create_benchmark_request, {"name": name, "url": url, "notes": notes})
508
+ console.print(f"Benchmark request [bold]{r['id']}[/bold] {r['status']}")
509
+
510
+
511
+ # ---------------------------------------------------------------------------- credits
512
+
513
+
514
+ @app.command()
515
+ def buy(
516
+ amount: float = typer.Argument(..., help="Credits to buy (1 credit = $1)"),
517
+ open_browser: bool = typer.Option(True, "--open/--no-open", help="Open the checkout page in your browser"),
518
+ ):
519
+ """Buy credits: creates a Stripe checkout page to pay on."""
520
+ url = call(client().checkout, amount)
521
+ console.print(f"Pay here: {url}")
522
+ if open_browser:
523
+ typer.launch(url)