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.
- benchmark_router_cli-0.1.0.dist-info/METADATA +9 -0
- benchmark_router_cli-0.1.0.dist-info/RECORD +8 -0
- benchmark_router_cli-0.1.0.dist-info/WHEEL +4 -0
- benchmark_router_cli-0.1.0.dist-info/entry_points.txt +2 -0
- brouter_cli/__init__.py +0 -0
- brouter_cli/client.py +95 -0
- brouter_cli/config.py +50 -0
- brouter_cli/main.py +523 -0
|
@@ -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,,
|
brouter_cli/__init__.py
ADDED
|
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)
|