brasskey 0.1.0a1__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.
- brasskey/__init__.py +11 -0
- brasskey/__main__.py +6 -0
- brasskey/bench/__init__.py +2 -0
- brasskey/bench/analysis.py +101 -0
- brasskey/bench/endpoint.py +133 -0
- brasskey/bench/guidellm.py +367 -0
- brasskey/bench/models.py +132 -0
- brasskey/bench/runner.py +73 -0
- brasskey/bench/stats.py +230 -0
- brasskey/bench/tokenizer.py +101 -0
- brasskey/cli/__init__.py +2 -0
- brasskey/cli/app.py +229 -0
- brasskey/cli/bench.py +591 -0
- brasskey/cli/doctor.py +174 -0
- brasskey/cli/options.py +49 -0
- brasskey/cli/run.py +766 -0
- brasskey/config/__init__.py +2 -0
- brasskey/config/intent.py +80 -0
- brasskey/config/io.py +89 -0
- brasskey/config/lock.py +108 -0
- brasskey/config/schema.py +34 -0
- brasskey/engines/__init__.py +6 -0
- brasskey/engines/base.py +204 -0
- brasskey/engines/capabilities.py +224 -0
- brasskey/engines/docker.py +216 -0
- brasskey/engines/gpu_failures.py +113 -0
- brasskey/engines/gpu_server.py +130 -0
- brasskey/engines/llamacpp.py +247 -0
- brasskey/engines/mlx.py +306 -0
- brasskey/engines/pyenv.py +74 -0
- brasskey/engines/registry.py +94 -0
- brasskey/engines/sglang.py +177 -0
- brasskey/engines/types.py +67 -0
- brasskey/engines/vllm.py +176 -0
- brasskey/errors.py +76 -0
- brasskey/hardware/__init__.py +31 -0
- brasskey/hardware/apple.py +165 -0
- brasskey/hardware/commands.py +81 -0
- brasskey/hardware/cpu.py +396 -0
- brasskey/hardware/nvidia.py +469 -0
- brasskey/hardware/osinfo.py +46 -0
- brasskey/hardware/probe.py +123 -0
- brasskey/hardware/types.py +175 -0
- brasskey/log.py +80 -0
- brasskey/models/__init__.py +2 -0
- brasskey/models/architecture.py +107 -0
- brasskey/models/catalog.py +46 -0
- brasskey/models/catalog.yaml +1123 -0
- brasskey/models/download.py +139 -0
- brasskey/models/fetch.py +120 -0
- brasskey/models/fit.py +297 -0
- brasskey/models/hub.py +445 -0
- brasskey/models/types.py +150 -0
- brasskey/output/__init__.py +6 -0
- brasskey/output/bench.py +167 -0
- brasskey/output/confirm.py +46 -0
- brasskey/output/envelope.py +48 -0
- brasskey/output/hardware.py +120 -0
- brasskey/output/next_step.py +27 -0
- brasskey/output/plan.py +166 -0
- brasskey/output/progress.py +59 -0
- brasskey/output/render.py +112 -0
- brasskey/output/result.py +25 -0
- brasskey/output/run.py +144 -0
- brasskey/output/security.py +46 -0
- brasskey/output/settings.py +70 -0
- brasskey/paths.py +57 -0
- brasskey/planner/__init__.py +12 -0
- brasskey/planner/replay.py +184 -0
- brasskey/planner/select.py +419 -0
- brasskey/py.typed +0 -0
- brasskey/runtime/__init__.py +2 -0
- brasskey/runtime/foreground.py +48 -0
- brasskey/runtime/ports.py +59 -0
- brasskey/runtime/proxy.py +264 -0
- brasskey/runtime/setup.py +65 -0
- brasskey/runtime/state.py +89 -0
- brasskey/runtime/supervisor.py +230 -0
- brasskey/security/__init__.py +2 -0
- brasskey/security/apikeys.py +110 -0
- brasskey/security/doctor.py +639 -0
- brasskey/security/netinfo.py +238 -0
- brasskey/security/policy.py +155 -0
- brasskey-0.1.0a1.dist-info/METADATA +150 -0
- brasskey-0.1.0a1.dist-info/RECORD +89 -0
- brasskey-0.1.0a1.dist-info/WHEEL +4 -0
- brasskey-0.1.0a1.dist-info/entry_points.txt +2 -0
- brasskey-0.1.0a1.dist-info/licenses/LICENSE +202 -0
- brasskey-0.1.0a1.dist-info/licenses/NOTICE +65 -0
brasskey/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Brasskey: the key to every engine.
|
|
3
|
+
|
|
4
|
+
Deploy, tune, verify and serve open models on any hardware, measured on your own traffic.
|
|
5
|
+
The core logic lives in importable modules; ``brasskey.cli`` is a thin command-line layer on top.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__all__ = ["__version__"]
|
|
9
|
+
|
|
10
|
+
# Single source of truth for the version (read by hatchling at build time).
|
|
11
|
+
__version__ = "0.1.0a1"
|
brasskey/__main__.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Turn repeated runs into metric summaries, and two endpoints into a verdict (pure functions)."""
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from brasskey.bench.models import (
|
|
8
|
+
METRICS,
|
|
9
|
+
ComparisonResult,
|
|
10
|
+
EndpointResult,
|
|
11
|
+
MetricComparison,
|
|
12
|
+
MetricInfo,
|
|
13
|
+
MetricSummary,
|
|
14
|
+
RunMetrics,
|
|
15
|
+
)
|
|
16
|
+
from brasskey.bench.stats import NOISY_CV, Goal, LogComparison, Verdict, compare_runs, mean_interval
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _values(runs: Sequence[RunMetrics], info: MetricInfo) -> tuple[float, ...]:
|
|
22
|
+
return tuple(float(getattr(run, info.key)) for run in runs)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def summarize(runs: Sequence[RunMetrics]) -> tuple[MetricSummary, ...]:
|
|
26
|
+
summaries: list[MetricSummary] = []
|
|
27
|
+
for info in METRICS:
|
|
28
|
+
values = _values(runs, info)
|
|
29
|
+
interval = mean_interval(values)
|
|
30
|
+
summaries.append(
|
|
31
|
+
MetricSummary(
|
|
32
|
+
key=info.key,
|
|
33
|
+
label=info.label,
|
|
34
|
+
unit=info.unit,
|
|
35
|
+
better=info.better,
|
|
36
|
+
values=values,
|
|
37
|
+
mean=interval.mean,
|
|
38
|
+
ci_low=interval.low,
|
|
39
|
+
ci_high=interval.high,
|
|
40
|
+
cv=interval.cv,
|
|
41
|
+
noisy=interval.cv is not None and interval.cv > NOISY_CV,
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
return tuple(summaries)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def compare(
|
|
48
|
+
baseline: EndpointResult,
|
|
49
|
+
candidate: EndpointResult,
|
|
50
|
+
*,
|
|
51
|
+
goal: Goal,
|
|
52
|
+
margin: float,
|
|
53
|
+
primary: str,
|
|
54
|
+
baseline_source: str,
|
|
55
|
+
) -> ComparisonResult:
|
|
56
|
+
"""Every metric compared; the primary metric decides the overall verdict."""
|
|
57
|
+
compared: list[MetricComparison] = []
|
|
58
|
+
for info in METRICS:
|
|
59
|
+
result = compare_runs(
|
|
60
|
+
baseline.metric(info.key).values,
|
|
61
|
+
candidate.metric(info.key).values,
|
|
62
|
+
better=info.better,
|
|
63
|
+
goal=goal,
|
|
64
|
+
margin=margin,
|
|
65
|
+
)
|
|
66
|
+
if isinstance(result, LogComparison):
|
|
67
|
+
compared.append(
|
|
68
|
+
MetricComparison(
|
|
69
|
+
key=info.key,
|
|
70
|
+
label=info.label,
|
|
71
|
+
better=info.better,
|
|
72
|
+
change=result.change,
|
|
73
|
+
change_low=result.change_low,
|
|
74
|
+
change_high=result.change_high,
|
|
75
|
+
verdict=result.verdict,
|
|
76
|
+
reason=result.reason,
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
else: # not enough data for an interval
|
|
80
|
+
compared.append(
|
|
81
|
+
MetricComparison(
|
|
82
|
+
key=info.key,
|
|
83
|
+
label=info.label,
|
|
84
|
+
better=info.better,
|
|
85
|
+
verdict=Verdict.INCONCLUSIVE,
|
|
86
|
+
reason=result,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
deciding = next(item for item in compared if item.key == primary)
|
|
90
|
+
logger.debug(
|
|
91
|
+
"Comparison on %s: %s (change %s)", primary, deciding.verdict.value, deciding.change
|
|
92
|
+
)
|
|
93
|
+
return ComparisonResult(
|
|
94
|
+
baseline_source="file" if baseline_source == "file" else "endpoint",
|
|
95
|
+
goal=goal,
|
|
96
|
+
margin=margin,
|
|
97
|
+
primary_metric=primary,
|
|
98
|
+
metrics=tuple(compared),
|
|
99
|
+
verdict=deciding.verdict,
|
|
100
|
+
reason=f"{deciding.label}: {deciding.reason}",
|
|
101
|
+
)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""The endpoints a benchmark talks to: URL checks, the served model, and Brasskey servers."""
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import cast
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
import httpx2
|
|
11
|
+
|
|
12
|
+
from brasskey.bench.guidellm import BenchError
|
|
13
|
+
from brasskey.output.next_step import NextStep
|
|
14
|
+
from brasskey.runtime.state import ServerRecord
|
|
15
|
+
from brasskey.security.apikeys import ApiKey
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
PROBE_TIMEOUT_S = 10.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Endpoint:
|
|
24
|
+
api_base: str # e.g. http://127.0.0.1:8000/v1
|
|
25
|
+
root: str # e.g. http://127.0.0.1:8000 (what GuideLLM calls the target)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def is_https(self) -> bool:
|
|
29
|
+
return self.api_base.startswith("https://")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_endpoint(url: str) -> Endpoint:
|
|
33
|
+
"""Accept ``http://host:port`` or ``http://host:port/v1`` (an OpenAI-compatible base URL)."""
|
|
34
|
+
parts = urlsplit(url.strip())
|
|
35
|
+
if parts.scheme not in ("http", "https") or not parts.hostname:
|
|
36
|
+
raise BenchError(
|
|
37
|
+
f"{url!r} isn't an http(s) URL.",
|
|
38
|
+
fix="Pass the server's base URL, e.g. http://127.0.0.1:8000/v1",
|
|
39
|
+
code="BK-E-USAGE",
|
|
40
|
+
)
|
|
41
|
+
if parts.username or parts.password:
|
|
42
|
+
raise BenchError(
|
|
43
|
+
"The URL contains credentials.",
|
|
44
|
+
fix="Remove them from the URL and put the API key in an environment variable "
|
|
45
|
+
"(BRASSKEY_API_KEY, or name one with --api-key-env).",
|
|
46
|
+
code="BK-E-USAGE",
|
|
47
|
+
)
|
|
48
|
+
path = parts.path.rstrip("/")
|
|
49
|
+
if path.endswith("/models"):
|
|
50
|
+
path = path.removesuffix("/models")
|
|
51
|
+
if not path.endswith("/v1"):
|
|
52
|
+
path = f"{path}/v1"
|
|
53
|
+
root = f"{parts.scheme}://{parts.netloc}{path.removesuffix('/v1')}"
|
|
54
|
+
return Endpoint(api_base=f"{root}/v1", root=root)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def served_models(client: httpx2.Client, endpoint: Endpoint, key: ApiKey | None) -> list[str]:
|
|
58
|
+
"""Model ids the endpoint lists; plain errors for the usual problems."""
|
|
59
|
+
headers = {"Authorization": f"Bearer {key.value}"} if key is not None else {}
|
|
60
|
+
try:
|
|
61
|
+
response = client.get(f"{endpoint.api_base}/models", headers=headers)
|
|
62
|
+
except httpx2.HTTPError as error:
|
|
63
|
+
raise BenchError(
|
|
64
|
+
f"Nothing answered at {endpoint.api_base}.",
|
|
65
|
+
fix="Start a server first (`brasskey run`), or check the URL and port.",
|
|
66
|
+
code="BK-E-BENCH-ENDPOINT",
|
|
67
|
+
next_step=NextStep(command="brasskey doctor", hint="see the running servers"),
|
|
68
|
+
) from error
|
|
69
|
+
if response.status_code in (401, 403):
|
|
70
|
+
raise BenchError(
|
|
71
|
+
f"{endpoint.api_base} needs an API key"
|
|
72
|
+
+ (" and refused this one." if key is not None else "."),
|
|
73
|
+
fix="Set BRASSKEY_API_KEY (or name the variable with --api-key-env), then run again.",
|
|
74
|
+
code="BK-E-BENCH-AUTH",
|
|
75
|
+
)
|
|
76
|
+
if response.status_code != 200:
|
|
77
|
+
raise BenchError(
|
|
78
|
+
f"{endpoint.api_base}/models answered HTTP {response.status_code}.",
|
|
79
|
+
fix="Check that this is an OpenAI-compatible server.",
|
|
80
|
+
code="BK-E-BENCH-ENDPOINT",
|
|
81
|
+
)
|
|
82
|
+
try:
|
|
83
|
+
body: object = response.json()
|
|
84
|
+
except ValueError as error:
|
|
85
|
+
raise BenchError(f"{endpoint.api_base}/models didn't return JSON.") from error
|
|
86
|
+
entries = cast(dict[str, object], body).get("data") if isinstance(body, dict) else None
|
|
87
|
+
ids = [
|
|
88
|
+
str(cast(dict[str, object], entry).get("id"))
|
|
89
|
+
for entry in (cast(list[object], entries) if isinstance(entries, list) else [])
|
|
90
|
+
if isinstance(entry, dict) and cast(dict[str, object], entry).get("id")
|
|
91
|
+
]
|
|
92
|
+
logger.debug("%s serves %d model(s)", endpoint.api_base, len(ids))
|
|
93
|
+
return ids
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def choose_model(endpoint: Endpoint, served: Sequence[str], requested: str | None) -> str:
|
|
97
|
+
if requested is not None:
|
|
98
|
+
if served and requested not in served:
|
|
99
|
+
raise BenchError(
|
|
100
|
+
f"{endpoint.api_base} doesn't serve {requested!r} (it serves {', '.join(served)}).",
|
|
101
|
+
fix="Pass one of those with --model.",
|
|
102
|
+
code="BK-E-USAGE",
|
|
103
|
+
)
|
|
104
|
+
return requested
|
|
105
|
+
if len(served) == 1:
|
|
106
|
+
return served[0]
|
|
107
|
+
if not served:
|
|
108
|
+
raise BenchError(
|
|
109
|
+
f"{endpoint.api_base} lists no models.",
|
|
110
|
+
fix="Pass the model name with --model.",
|
|
111
|
+
code="BK-E-USAGE",
|
|
112
|
+
)
|
|
113
|
+
raise BenchError(
|
|
114
|
+
f"{endpoint.api_base} serves several models: {', '.join(served)}.",
|
|
115
|
+
fix="Pick one with --model.",
|
|
116
|
+
code="BK-E-USAGE",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def brasskey_server(endpoint: Endpoint, records: Sequence[ServerRecord]) -> ServerRecord | None:
|
|
121
|
+
"""The running Brasskey server behind this URL, if any (to label results with its engine)."""
|
|
122
|
+
for record in records:
|
|
123
|
+
if record.base_url.rstrip("/") == endpoint.api_base:
|
|
124
|
+
return record
|
|
125
|
+
loopback = {"127.0.0.1", "localhost", "[::1]"}
|
|
126
|
+
mine, theirs = urlsplit(endpoint.api_base), urlsplit(record.base_url)
|
|
127
|
+
if (
|
|
128
|
+
mine.port == theirs.port
|
|
129
|
+
and mine.hostname in loopback | {"::1"}
|
|
130
|
+
and theirs.hostname in loopback | {"::1"}
|
|
131
|
+
):
|
|
132
|
+
return record
|
|
133
|
+
return None
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""GuideLLM (Apache-2.0, vLLM project) as Brasskey's load generator, run as a separate process.
|
|
3
|
+
|
|
4
|
+
Where it comes from (ADR 0002, ADR 0011):
|
|
5
|
+
- ``guidellm`` on PATH: used as is;
|
|
6
|
+
- otherwise a pinned, isolated environment run by ``uv tool run``: GuideLLM 0.8.1 with its
|
|
7
|
+
dependencies resolved as of a fixed date and CPU-only PyTorch (GuideLLM only tokenizes; CUDA
|
|
8
|
+
wheels would add gigabytes). Set up once after the user confirms; later runs start offline.
|
|
9
|
+
|
|
10
|
+
Each run (checked against GuideLLM 0.8.1):
|
|
11
|
+
- synthetic prompts of a fixed token shape, a fixed number of concurrent streams, a fixed
|
|
12
|
+
duration and a seed;
|
|
13
|
+
- ``--metrics sample_size=0``: GuideLLM keeps no prompt or response text, only numbers;
|
|
14
|
+
- the API key reaches GuideLLM through ``GUIDELLM__SPEC__BACKEND__API_KEY`` (its settings
|
|
15
|
+
variable), never argv; GuideLLM masks it in its report;
|
|
16
|
+
- it runs in a private folder (so no stray ``.env`` there can change it) and writes its JSON
|
|
17
|
+
report there; Brasskey reads the report and deletes it.
|
|
18
|
+
|
|
19
|
+
The report parser checks GuideLLM's report format version and refuses unknown ones, so a
|
|
20
|
+
GuideLLM upgrade can't silently change what the numbers mean.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import os
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Literal, cast
|
|
32
|
+
|
|
33
|
+
from brasskey.bench.models import RunMetrics, Workload
|
|
34
|
+
from brasskey.engines.base import ENGINE_BASE_ENV
|
|
35
|
+
from brasskey.errors import BrasskeyError
|
|
36
|
+
from brasskey.hardware.commands import CommandRunner, run_command
|
|
37
|
+
from brasskey.paths import open_private_file
|
|
38
|
+
from brasskey.security.apikeys import ApiKey
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
GUIDELLM_VERSION = "0.8.1"
|
|
43
|
+
RESOLVE_CUTOFF = "2026-11-01T00:00:00Z" # dependencies resolved as of this date: reproducible
|
|
44
|
+
UV_PYTHON = "3.12"
|
|
45
|
+
SETUP_NOTE = "about 300 MB to download, 1 GB on disk, one time"
|
|
46
|
+
REPORT_FORMAT_VERSION = 2 # GuideLLM 0.8 reports: metadata.version
|
|
47
|
+
KEY_ENV = "GUIDELLM__SPEC__BACKEND__API_KEY"
|
|
48
|
+
EXECUTABLE = "guidellm"
|
|
49
|
+
FALLBACK_DIRS = (Path.home() / ".local" / "bin", Path("/opt/homebrew/bin"), Path("/usr/local/bin"))
|
|
50
|
+
DOCS_URL = "https://github.com/vllm-project/guidellm"
|
|
51
|
+
RUN_GRACE_S = 300.0 # start-up, tokenizer loading and finishing in-flight requests
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BenchError(BrasskeyError):
|
|
55
|
+
default_code = "BK-E-BENCH"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ReportFormatError(BenchError):
|
|
59
|
+
default_code = "BK-E-BENCH-REPORT"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class GuidellmInstall:
|
|
64
|
+
version: str
|
|
65
|
+
runtime: Literal["process", "uvx"]
|
|
66
|
+
executable: tuple[str, ...]
|
|
67
|
+
needs_setup: bool = False
|
|
68
|
+
setup_argv: tuple[str, ...] = ()
|
|
69
|
+
setup_note: str | None = None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def label(self) -> str:
|
|
73
|
+
return f"GuideLLM {self.version}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class RunTarget:
|
|
78
|
+
"""One endpoint as GuideLLM sees it."""
|
|
79
|
+
|
|
80
|
+
target: str # server root, e.g. http://127.0.0.1:8000 (GuideLLM adds /v1/...)
|
|
81
|
+
model: str
|
|
82
|
+
tokenizer: str # local folder or Hugging Face id
|
|
83
|
+
tokenizer_local: bool
|
|
84
|
+
api_key: ApiKey | None = field(default=None, repr=False)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def uv_base_command(uv: str, *, offline: bool = False) -> tuple[str, ...]:
|
|
88
|
+
"""``uv tool run`` with the pinned GuideLLM environment (its arguments come after)."""
|
|
89
|
+
return (
|
|
90
|
+
uv, "tool", "run", *(("--offline",) if offline else ()),
|
|
91
|
+
"--managed-python", "--python", UV_PYTHON,
|
|
92
|
+
"--exclude-newer", RESOLVE_CUTOFF,
|
|
93
|
+
"--torch-backend", "cpu",
|
|
94
|
+
"--from", f"guidellm=={GUIDELLM_VERSION}",
|
|
95
|
+
EXECUTABLE,
|
|
96
|
+
) # fmt: skip
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_version(text: str) -> str | None:
|
|
100
|
+
"""``guidellm version: 0.8.1`` -> ``0.8.1``."""
|
|
101
|
+
for line in text.splitlines():
|
|
102
|
+
if "version" in line.lower() and ":" in line:
|
|
103
|
+
value = line.split(":", 1)[1].strip()
|
|
104
|
+
if value and value[0].isdigit():
|
|
105
|
+
return value
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _find(name: str, which: Callable[[str], str | None], dirs: Sequence[Path]) -> str | None:
|
|
110
|
+
found = which(name)
|
|
111
|
+
if found:
|
|
112
|
+
return found
|
|
113
|
+
for directory in dirs:
|
|
114
|
+
candidate = directory / name
|
|
115
|
+
if candidate.is_file():
|
|
116
|
+
return str(candidate)
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def detect_guidellm(
|
|
121
|
+
*,
|
|
122
|
+
run: CommandRunner = run_command,
|
|
123
|
+
which: Callable[[str], str | None] = shutil.which,
|
|
124
|
+
fallback_dirs: Sequence[Path] = FALLBACK_DIRS,
|
|
125
|
+
) -> GuidellmInstall | None:
|
|
126
|
+
"""GuideLLM on PATH, else the pinned uv environment (maybe not set up yet), else None."""
|
|
127
|
+
on_path = _find(EXECUTABLE, which, fallback_dirs)
|
|
128
|
+
if on_path is not None:
|
|
129
|
+
out = run([on_path, "--version"], timeout_s=60)
|
|
130
|
+
version = parse_version(out.stdout + out.stderr) if out.ok else None
|
|
131
|
+
if version is not None:
|
|
132
|
+
logger.debug("GuideLLM on PATH: %s %s", on_path, version)
|
|
133
|
+
return GuidellmInstall(version=version, runtime="process", executable=(on_path,))
|
|
134
|
+
logger.debug("%s --version failed (exit %d)", on_path, out.returncode)
|
|
135
|
+
uv = _find("uv", which, fallback_dirs)
|
|
136
|
+
if uv is None:
|
|
137
|
+
return None
|
|
138
|
+
ready = run([*uv_base_command(uv, offline=True), "--version"], timeout_s=60)
|
|
139
|
+
if ready.ok and parse_version(ready.stdout + ready.stderr):
|
|
140
|
+
logger.debug("Pinned GuideLLM environment ready")
|
|
141
|
+
return GuidellmInstall(
|
|
142
|
+
version=GUIDELLM_VERSION, runtime="uvx", executable=uv_base_command(uv)
|
|
143
|
+
)
|
|
144
|
+
logger.debug("Pinned GuideLLM environment not set up yet (uv exit %d)", ready.returncode)
|
|
145
|
+
return GuidellmInstall(
|
|
146
|
+
version=GUIDELLM_VERSION,
|
|
147
|
+
runtime="uvx",
|
|
148
|
+
executable=uv_base_command(uv),
|
|
149
|
+
needs_setup=True,
|
|
150
|
+
setup_argv=(*uv_base_command(uv), "--version"),
|
|
151
|
+
setup_note=SETUP_NOTE,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def install_hint() -> tuple[str, tuple[str, ...]]:
|
|
156
|
+
return (
|
|
157
|
+
"Install uv so Brasskey can set GuideLLM up automatically (or install GuideLLM).",
|
|
158
|
+
("brew install uv", f"or: uv tool install guidellm=={GUIDELLM_VERSION}"),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _json(value: Mapping[str, object]) -> str:
|
|
163
|
+
return json.dumps(value, separators=(",", ":"), sort_keys=True)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def build_argv(
|
|
167
|
+
install: GuidellmInstall,
|
|
168
|
+
target: RunTarget,
|
|
169
|
+
workload: Workload,
|
|
170
|
+
*,
|
|
171
|
+
seed: int,
|
|
172
|
+
duration_s: int,
|
|
173
|
+
report_path: Path,
|
|
174
|
+
) -> list[str]:
|
|
175
|
+
"""The ``guidellm run`` command for one run (JSON options: paths may contain commas)."""
|
|
176
|
+
return [
|
|
177
|
+
*install.executable,
|
|
178
|
+
"run",
|
|
179
|
+
"--backend", _json({"kind": "openai_http", "target": target.target, "model": target.model}),
|
|
180
|
+
"--tokenizer", _json({"kind": "huggingface_auto", "model": target.tokenizer}),
|
|
181
|
+
"--data", _json({"kind": "synthetic_text", "prompt_tokens": workload.prompt_tokens,
|
|
182
|
+
"output_tokens": workload.output_tokens}),
|
|
183
|
+
"--profile", _json({"kind": "concurrent", "streams": workload.concurrency}),
|
|
184
|
+
"--constraint", _json({"kind": "max_duration", "seconds": duration_s}),
|
|
185
|
+
"--seed", _json({"kind": "static", "value": seed}),
|
|
186
|
+
"--metrics", _json({"kind": "generative", "sample_size": 0}),
|
|
187
|
+
"--output", _json({"kind": "json", "path": str(report_path)}),
|
|
188
|
+
"--disable-console",
|
|
189
|
+
] # fmt: skip
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def run_environment(target: RunTarget, base: Mapping[str, str]) -> dict[str, str]:
|
|
193
|
+
"""GuideLLM's environment: no Brasskey keys, no telemetry, and the target's key by name."""
|
|
194
|
+
env = {
|
|
195
|
+
name: value
|
|
196
|
+
for name, value in base.items()
|
|
197
|
+
if not name.startswith("GUIDELLM__") and "API_KEY" not in name
|
|
198
|
+
}
|
|
199
|
+
env.update(ENGINE_BASE_ENV)
|
|
200
|
+
env["HF_HUB_OFFLINE"] = "1" if target.tokenizer_local else "0"
|
|
201
|
+
if target.api_key is not None:
|
|
202
|
+
env[KEY_ENV] = target.api_key.value
|
|
203
|
+
return env
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# --- the report --------------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _path(data: object, *keys: str) -> object:
|
|
210
|
+
current = data
|
|
211
|
+
for key in keys:
|
|
212
|
+
if not isinstance(current, dict):
|
|
213
|
+
raise ReportFormatError(
|
|
214
|
+
f"GuideLLM's report has no {'.'.join(keys)}.",
|
|
215
|
+
fix=f"Use GuideLLM {GUIDELLM_VERSION} (let Brasskey run its pinned copy by "
|
|
216
|
+
"removing other guidellm installs from PATH).",
|
|
217
|
+
)
|
|
218
|
+
current = cast(dict[str, object], current).get(key)
|
|
219
|
+
return current
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _number(data: object, *keys: str) -> float:
|
|
223
|
+
value = _path(data, *keys)
|
|
224
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
225
|
+
raise ReportFormatError(
|
|
226
|
+
f"GuideLLM's report has no number at {'.'.join(keys)}.",
|
|
227
|
+
fix=f"Use GuideLLM {GUIDELLM_VERSION}.",
|
|
228
|
+
)
|
|
229
|
+
return float(value)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def parse_report(text: str, *, seed: int) -> RunMetrics:
|
|
233
|
+
"""The numbers Brasskey uses from one GuideLLM JSON report (format version 2)."""
|
|
234
|
+
try:
|
|
235
|
+
data: object = json.loads(text)
|
|
236
|
+
except ValueError as error:
|
|
237
|
+
raise ReportFormatError("GuideLLM's report isn't valid JSON.") from error
|
|
238
|
+
version = _path(data, "metadata", "version")
|
|
239
|
+
if version != REPORT_FORMAT_VERSION:
|
|
240
|
+
tool = _path(data, "metadata", "guidellm_version")
|
|
241
|
+
raise ReportFormatError(
|
|
242
|
+
f"GuideLLM {tool} writes report format {version}; this Brasskey reads format "
|
|
243
|
+
f"{REPORT_FORMAT_VERSION} (GuideLLM 0.8).",
|
|
244
|
+
fix=f"Use GuideLLM {GUIDELLM_VERSION}: remove other guidellm installs from PATH so "
|
|
245
|
+
"Brasskey runs its pinned copy, or update Brasskey.",
|
|
246
|
+
)
|
|
247
|
+
benchmarks = _path(data, "benchmarks")
|
|
248
|
+
if not isinstance(benchmarks, list) or len(cast(list[object], benchmarks)) != 1:
|
|
249
|
+
raise ReportFormatError("GuideLLM's report should hold exactly one benchmark.")
|
|
250
|
+
bench: object = cast(list[object], benchmarks)[0]
|
|
251
|
+
metrics = _path(bench, "metrics")
|
|
252
|
+
ok = ("successful",)
|
|
253
|
+
return RunMetrics(
|
|
254
|
+
seed=seed,
|
|
255
|
+
duration_s=_number(bench, "duration"),
|
|
256
|
+
successful=int(_number(metrics, "request_totals", "successful")),
|
|
257
|
+
errored=int(_number(metrics, "request_totals", "errored")),
|
|
258
|
+
incomplete=int(_number(metrics, "request_totals", "incomplete")),
|
|
259
|
+
output_tokens_per_s=_number(metrics, "output_tokens_per_second", *ok, "mean"),
|
|
260
|
+
requests_per_s=_number(metrics, "requests_per_second", *ok, "mean"),
|
|
261
|
+
ttft_ms_p50=_number(metrics, "time_to_first_token_ms", *ok, "median"),
|
|
262
|
+
ttft_ms_p95=_number(metrics, "time_to_first_token_ms", *ok, "percentiles", "p95"),
|
|
263
|
+
itl_ms_p50=_number(metrics, "inter_token_latency_ms", *ok, "median"),
|
|
264
|
+
latency_s_p50=_number(metrics, "request_latency", *ok, "median"),
|
|
265
|
+
latency_s_p95=_number(metrics, "request_latency", *ok, "percentiles", "p95"),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# --- one run ---------------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
_FAILURES: tuple[tuple[tuple[str, ...], str, str], ...] = (
|
|
273
|
+
(
|
|
274
|
+
("401", "unauthorized", "invalid_api_key"),
|
|
275
|
+
"The endpoint refused GuideLLM's requests (API key).",
|
|
276
|
+
"Set the key's variable (BRASSKEY_API_KEY or --api-key-env), then run again.",
|
|
277
|
+
),
|
|
278
|
+
(
|
|
279
|
+
("connecterror", "connection refused", "all connection attempts failed"),
|
|
280
|
+
"GuideLLM couldn't reach the endpoint.",
|
|
281
|
+
"Check that the server is still running (`brasskey doctor` lists Brasskey servers).",
|
|
282
|
+
),
|
|
283
|
+
(
|
|
284
|
+
("can't load tokenizer", "tokenizer", "not a valid model identifier"),
|
|
285
|
+
"GuideLLM couldn't load the tokenizer.",
|
|
286
|
+
"Pass --tokenizer with the model's Hugging Face id or a local folder with tokenizer.json.",
|
|
287
|
+
),
|
|
288
|
+
(
|
|
289
|
+
("no such option", "invalid value for", "unrecognized arguments"),
|
|
290
|
+
"This GuideLLM version doesn't accept Brasskey's options.",
|
|
291
|
+
f"Use GuideLLM {GUIDELLM_VERSION}: remove other guidellm installs from PATH so Brasskey "
|
|
292
|
+
"runs its pinned copy.",
|
|
293
|
+
),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def explain_failure(log_tail: Sequence[str]) -> tuple[str, str] | None:
|
|
298
|
+
text = "\n".join(log_tail).lower()
|
|
299
|
+
for fragments, message, fix in _FAILURES:
|
|
300
|
+
if any(fragment in text for fragment in fragments):
|
|
301
|
+
return message, fix
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def run_once(
|
|
306
|
+
install: GuidellmInstall,
|
|
307
|
+
target: RunTarget,
|
|
308
|
+
workload: Workload,
|
|
309
|
+
*,
|
|
310
|
+
seed: int,
|
|
311
|
+
duration_s: int,
|
|
312
|
+
work_dir: Path,
|
|
313
|
+
log_path: Path,
|
|
314
|
+
env: Mapping[str, str] | None = None,
|
|
315
|
+
) -> RunMetrics:
|
|
316
|
+
"""One GuideLLM run; its raw report (which may hold request details) is deleted at once."""
|
|
317
|
+
report = work_dir / f"report-{seed}.json"
|
|
318
|
+
argv = build_argv(
|
|
319
|
+
install, target, workload, seed=seed, duration_s=duration_s, report_path=report
|
|
320
|
+
)
|
|
321
|
+
environment = run_environment(target, os.environ if env is None else env)
|
|
322
|
+
fd = open_private_file(log_path, append=True) # one log for the whole bench
|
|
323
|
+
try:
|
|
324
|
+
logger.debug(
|
|
325
|
+
"GuideLLM run: seed=%d duration=%ds target=%s", seed, duration_s, target.target
|
|
326
|
+
)
|
|
327
|
+
# argv is built here from fixed options and values Brasskey checked; never a shell.
|
|
328
|
+
completed = subprocess.run( # noqa: S603
|
|
329
|
+
argv,
|
|
330
|
+
stdin=subprocess.DEVNULL,
|
|
331
|
+
stdout=fd,
|
|
332
|
+
stderr=subprocess.STDOUT,
|
|
333
|
+
cwd=work_dir,
|
|
334
|
+
env=environment,
|
|
335
|
+
timeout=duration_s + RUN_GRACE_S,
|
|
336
|
+
check=False,
|
|
337
|
+
)
|
|
338
|
+
except subprocess.TimeoutExpired as error:
|
|
339
|
+
raise BenchError(
|
|
340
|
+
f"GuideLLM didn't finish within {duration_s + RUN_GRACE_S:.0f}s.",
|
|
341
|
+
fix=f"Check that the endpoint still answers. GuideLLM's log: {log_path}",
|
|
342
|
+
) from error
|
|
343
|
+
except FileNotFoundError as error:
|
|
344
|
+
raise BenchError(
|
|
345
|
+
f"GuideLLM couldn't start ({argv[0]} not found).",
|
|
346
|
+
fix="Install uv (`brew install uv`) and run again.",
|
|
347
|
+
) from error
|
|
348
|
+
finally:
|
|
349
|
+
os.close(fd)
|
|
350
|
+
try:
|
|
351
|
+
if completed.returncode != 0 or not report.is_file():
|
|
352
|
+
tail = log_path.read_text(encoding="utf-8", errors="replace").splitlines()[-40:]
|
|
353
|
+
explained = explain_failure(tail)
|
|
354
|
+
message, fix = explained or (
|
|
355
|
+
f"GuideLLM failed (exit code {completed.returncode}).",
|
|
356
|
+
"See its log for details.",
|
|
357
|
+
)
|
|
358
|
+
raise BenchError(message, fix=f"{fix} GuideLLM's log: {log_path}")
|
|
359
|
+
metrics = parse_report(report.read_text(encoding="utf-8"), seed=seed)
|
|
360
|
+
finally:
|
|
361
|
+
report.unlink(missing_ok=True)
|
|
362
|
+
if metrics.successful == 0:
|
|
363
|
+
raise BenchError(
|
|
364
|
+
f"No request succeeded ({metrics.errored} failed).",
|
|
365
|
+
fix=f"Check the endpoint with `curl`, then run again. GuideLLM's log: {log_path}",
|
|
366
|
+
)
|
|
367
|
+
return metrics
|