inferbench-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.
- inferbench/__init__.py +59 -0
- inferbench/benchmark.py +121 -0
- inferbench/cli.py +173 -0
- inferbench/cost/__init__.py +0 -0
- inferbench/cost/cloud_comparison.py +42 -0
- inferbench/engines/__init__.py +0 -0
- inferbench/engines/llamacpp.py +61 -0
- inferbench/engines/omlx.py +97 -0
- inferbench/engines/registry.py +31 -0
- inferbench/errors.py +48 -0
- inferbench/hardware/__init__.py +0 -0
- inferbench/hardware/detect.py +109 -0
- inferbench/harness/__init__.py +0 -0
- inferbench/harness/measure.py +103 -0
- inferbench/harness/spawn_server.py +69 -0
- inferbench/prompts.py +22 -0
- inferbench/py.typed +0 -0
- inferbench/recommend/__init__.py +0 -0
- inferbench/recommend/config.py +29 -0
- inferbench/report/__init__.py +0 -0
- inferbench/report/json_report.py +66 -0
- inferbench/types.py +95 -0
- inferbench_cli-0.1.0.dist-info/METADATA +221 -0
- inferbench_cli-0.1.0.dist-info/RECORD +27 -0
- inferbench_cli-0.1.0.dist-info/WHEEL +4 -0
- inferbench_cli-0.1.0.dist-info/entry_points.txt +2 -0
- inferbench_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
inferbench/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Programmatic / agent-native entry point.
|
|
3
|
+
|
|
4
|
+
from inferbench import benchmark_engine, detect_hardware, all_engines
|
|
5
|
+
|
|
6
|
+
hardware = detect_hardware()
|
|
7
|
+
adapters = all_engines()
|
|
8
|
+
results = [benchmark_engine(a, model="bartowski/Qwen2.5-1.5B-Instruct-GGUF:Q4_K_M") for a in adapters]
|
|
9
|
+
|
|
10
|
+
Same core orchestration the CLI calls; inferbench.cli is a thin
|
|
11
|
+
argument-parsing wrapper over these functions.
|
|
12
|
+
|
|
13
|
+
This is the Python port of the inferbench-cli npm package
|
|
14
|
+
(https://www.npmjs.com/package/inferbench-cli). Both distributions
|
|
15
|
+
benchmark the same two supported local-inference engines (omlx,
|
|
16
|
+
llama.cpp) against a fixed, varied prompt set over each engine's own
|
|
17
|
+
OpenAI-compatible HTTP server; see
|
|
18
|
+
https://github.com/RudrenduPaul/InferBench for the canonical
|
|
19
|
+
documentation and the original TypeScript source.
|
|
20
|
+
"""
|
|
21
|
+
from .benchmark import benchmark_engine
|
|
22
|
+
from .cost.cloud_comparison import CLOUD_PRICING_PER_1K_OUTPUT_TOKENS, CostComparison, compare_to_cloud
|
|
23
|
+
from .engines.registry import SUPPORTED_ENGINES, all_engines, resolve_engines
|
|
24
|
+
from .hardware.detect import detect_hardware
|
|
25
|
+
from .recommend.config import recommend
|
|
26
|
+
from .report.json_report import report_to_dict, write_json_report
|
|
27
|
+
from .types import (
|
|
28
|
+
BenchmarkReport,
|
|
29
|
+
CompletionResult,
|
|
30
|
+
EngineBenchmarkResult,
|
|
31
|
+
HardwareProfile,
|
|
32
|
+
PromptRunResult,
|
|
33
|
+
Recommendation,
|
|
34
|
+
StartedServer,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"benchmark_engine",
|
|
41
|
+
"detect_hardware",
|
|
42
|
+
"all_engines",
|
|
43
|
+
"resolve_engines",
|
|
44
|
+
"recommend",
|
|
45
|
+
"compare_to_cloud",
|
|
46
|
+
"report_to_dict",
|
|
47
|
+
"write_json_report",
|
|
48
|
+
"SUPPORTED_ENGINES",
|
|
49
|
+
"CLOUD_PRICING_PER_1K_OUTPUT_TOKENS",
|
|
50
|
+
"BenchmarkReport",
|
|
51
|
+
"CompletionResult",
|
|
52
|
+
"CostComparison",
|
|
53
|
+
"EngineBenchmarkResult",
|
|
54
|
+
"HardwareProfile",
|
|
55
|
+
"PromptRunResult",
|
|
56
|
+
"Recommendation",
|
|
57
|
+
"StartedServer",
|
|
58
|
+
"__version__",
|
|
59
|
+
]
|
inferbench/benchmark.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ported from src/benchmark.ts. Benchmarks a single engine. Never raises for
|
|
3
|
+
an engine-level failure (not installed, server start timeout, request
|
|
4
|
+
failure) -- engines are isolated from each other by design, so one bad
|
|
5
|
+
engine never blocks results from the others. Callers get a result object
|
|
6
|
+
with installed=False or per-run errors instead.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Callable, List, Optional
|
|
11
|
+
|
|
12
|
+
from .harness.measure import TimedCompletionOptions, timed_completion
|
|
13
|
+
from .prompts import DEFAULT_PROMPTS, WARMUP_PROMPT
|
|
14
|
+
from .types import EngineAdapter, EngineBenchmarkResult, PromptRunResult
|
|
15
|
+
|
|
16
|
+
DEFAULT_MAX_TOKENS = 200
|
|
17
|
+
DEFAULT_SERVER_START_TIMEOUT_MS = 5 * 60 * 1000
|
|
18
|
+
DEFAULT_REQUEST_TIMEOUT_MS = 2 * 60 * 1000
|
|
19
|
+
|
|
20
|
+
_next_port = 41000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _claim_port() -> int:
|
|
24
|
+
global _next_port
|
|
25
|
+
port = _next_port
|
|
26
|
+
_next_port += 1
|
|
27
|
+
return port
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def benchmark_engine(
|
|
31
|
+
adapter: EngineAdapter,
|
|
32
|
+
*,
|
|
33
|
+
model: str,
|
|
34
|
+
max_tokens: Optional[int] = None,
|
|
35
|
+
server_start_timeout_ms: Optional[int] = None,
|
|
36
|
+
request_timeout_ms: Optional[int] = None,
|
|
37
|
+
prompts: Optional[List[str]] = None,
|
|
38
|
+
verbose: bool = False,
|
|
39
|
+
on_progress: Optional[Callable[[str], None]] = None,
|
|
40
|
+
) -> EngineBenchmarkResult:
|
|
41
|
+
progress = on_progress or (lambda _line: None)
|
|
42
|
+
prompt_list = prompts if prompts is not None else DEFAULT_PROMPTS
|
|
43
|
+
tokens_cap = max_tokens if max_tokens is not None else DEFAULT_MAX_TOKENS
|
|
44
|
+
|
|
45
|
+
if not adapter.is_installed():
|
|
46
|
+
progress(f"{adapter.name}: not installed, skipped")
|
|
47
|
+
return EngineBenchmarkResult(
|
|
48
|
+
engine=adapter.name,
|
|
49
|
+
installed=False,
|
|
50
|
+
error=f'binary "{adapter.binary}" not found',
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
progress(f"{adapter.name}: starting server...")
|
|
54
|
+
try:
|
|
55
|
+
server = adapter.start_server(
|
|
56
|
+
model=model,
|
|
57
|
+
port=_claim_port(),
|
|
58
|
+
timeout_ms=server_start_timeout_ms or DEFAULT_SERVER_START_TIMEOUT_MS,
|
|
59
|
+
verbose=verbose,
|
|
60
|
+
)
|
|
61
|
+
except Exception as err: # noqa: BLE001 -- any start-up failure reports as a failed result, never raises
|
|
62
|
+
message = str(err)
|
|
63
|
+
progress(f"{adapter.name}: failed to start ({message})")
|
|
64
|
+
return EngineBenchmarkResult(engine=adapter.name, installed=True, error=message)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
progress(f"{adapter.name}: warming up...")
|
|
68
|
+
try:
|
|
69
|
+
timed_completion(
|
|
70
|
+
TimedCompletionOptions(
|
|
71
|
+
engine=adapter.name,
|
|
72
|
+
base_url=server.base_url,
|
|
73
|
+
model_id=server.model_id,
|
|
74
|
+
prompt=WARMUP_PROMPT,
|
|
75
|
+
max_tokens=16,
|
|
76
|
+
timeout_ms=request_timeout_ms or DEFAULT_REQUEST_TIMEOUT_MS,
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
except Exception as err: # noqa: BLE001 -- any warm-up failure reports as a failed result
|
|
80
|
+
message = str(err)
|
|
81
|
+
progress(f"{adapter.name}: warm-up failed ({message})")
|
|
82
|
+
return EngineBenchmarkResult(
|
|
83
|
+
engine=adapter.name,
|
|
84
|
+
installed=True,
|
|
85
|
+
error=f"warm-up failed: {message}",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
runs: List[PromptRunResult] = []
|
|
89
|
+
for i, prompt in enumerate(prompt_list):
|
|
90
|
+
progress(f"{adapter.name}: [{i + 1}/{len(prompt_list)}] benchmarking...")
|
|
91
|
+
try:
|
|
92
|
+
result = timed_completion(
|
|
93
|
+
TimedCompletionOptions(
|
|
94
|
+
engine=adapter.name,
|
|
95
|
+
base_url=server.base_url,
|
|
96
|
+
model_id=server.model_id,
|
|
97
|
+
prompt=prompt,
|
|
98
|
+
max_tokens=tokens_cap,
|
|
99
|
+
timeout_ms=request_timeout_ms or DEFAULT_REQUEST_TIMEOUT_MS,
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
runs.append(PromptRunResult(prompt=prompt, ok=True, result=result))
|
|
103
|
+
except Exception as err: # noqa: BLE001 -- one failed prompt must not abort the sweep
|
|
104
|
+
runs.append(PromptRunResult(prompt=prompt, ok=False, error=str(err)))
|
|
105
|
+
|
|
106
|
+
valid = [
|
|
107
|
+
r.result.tokens_per_second
|
|
108
|
+
for r in runs
|
|
109
|
+
if r.ok and r.result is not None and r.result.tokens_per_second
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
return EngineBenchmarkResult(
|
|
113
|
+
engine=adapter.name,
|
|
114
|
+
installed=True,
|
|
115
|
+
runs=runs,
|
|
116
|
+
avg_tokens_per_second=round(sum(valid) / len(valid), 2) if valid else None,
|
|
117
|
+
min_tokens_per_second=min(valid) if valid else None,
|
|
118
|
+
max_tokens_per_second=max(valid) if valid else None,
|
|
119
|
+
)
|
|
120
|
+
finally:
|
|
121
|
+
server.stop()
|
inferbench/cli.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Ported from src/cli.ts (which uses `commander`); this port uses the stdlib
|
|
4
|
+
`argparse` to avoid a CLI-framework dependency. Flags and defaults are kept
|
|
5
|
+
identical in name and meaning to the npm CLI. Console entry point:
|
|
6
|
+
`inferbench run [options]`, installed via the `inferbench` console-script
|
|
7
|
+
defined in python/pyproject.toml.
|
|
8
|
+
|
|
9
|
+
One deliberate, documented divergence from the TypeScript CLI: argparse's
|
|
10
|
+
own parse errors (a missing required --model, an unrecognized flag) exit
|
|
11
|
+
with code 2, the standard argparse/Unix convention, rather than commander's
|
|
12
|
+
exit code 1 for the same case. The "ran, but no supported engine was
|
|
13
|
+
installed" case still exits 1 on both CLIs, matching NoEnginesFoundError's
|
|
14
|
+
documented contract. See docs/concepts.md for the full exit-code table.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json as json_module
|
|
20
|
+
import sys
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from typing import List
|
|
23
|
+
|
|
24
|
+
from .benchmark import benchmark_engine
|
|
25
|
+
from .engines.registry import SUPPORTED_ENGINES, all_engines, resolve_engines
|
|
26
|
+
from .errors import NoEnginesFoundError, UsageError
|
|
27
|
+
from .hardware.detect import detect_hardware
|
|
28
|
+
from .recommend.config import recommend
|
|
29
|
+
from .report.json_report import report_to_dict, write_json_report
|
|
30
|
+
from .types import BenchmarkReport
|
|
31
|
+
|
|
32
|
+
_VERSION = "0.1.0"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
36
|
+
parser = argparse.ArgumentParser(
|
|
37
|
+
prog="inferbench",
|
|
38
|
+
description=(
|
|
39
|
+
"Benchmarks local-LLM-inference engines (omlx, llama.cpp) on your own "
|
|
40
|
+
"hardware, live."
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument("--version", action="version", version=f"inferbench {_VERSION}")
|
|
44
|
+
|
|
45
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
46
|
+
|
|
47
|
+
run_parser = subparsers.add_parser("run", help="Benchmark installed engines against a model")
|
|
48
|
+
run_parser.add_argument(
|
|
49
|
+
"--model", required=True, help="Model spec (engine-specific, see README)"
|
|
50
|
+
)
|
|
51
|
+
run_parser.add_argument(
|
|
52
|
+
"--engines",
|
|
53
|
+
default=None,
|
|
54
|
+
help=(
|
|
55
|
+
"Comma-separated engines to test (default: all supported -- "
|
|
56
|
+
f"{', '.join(SUPPORTED_ENGINES)})"
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
run_parser.add_argument(
|
|
60
|
+
"--max-tokens", default="200", help="Max completion tokens per prompt"
|
|
61
|
+
)
|
|
62
|
+
run_parser.add_argument(
|
|
63
|
+
"--json", action="store_true", help="Output machine-readable JSON instead of a human table"
|
|
64
|
+
)
|
|
65
|
+
run_parser.add_argument("--out", default=None, help="Also write the full JSON report to this file")
|
|
66
|
+
run_parser.add_argument(
|
|
67
|
+
"--verbose", action="store_true", help="Show raw engine server stdout/stderr"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return parser
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _parse_max_tokens(value: str) -> int:
|
|
74
|
+
try:
|
|
75
|
+
parsed = int(value)
|
|
76
|
+
except ValueError:
|
|
77
|
+
raise UsageError(
|
|
78
|
+
f'Invalid --max-tokens value "{value}": must be a whole number, e.g. --max-tokens 200'
|
|
79
|
+
) from None
|
|
80
|
+
if parsed <= 0:
|
|
81
|
+
raise UsageError(f'Invalid --max-tokens value "{value}": must be a positive number')
|
|
82
|
+
return parsed
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _run_command(args: argparse.Namespace) -> None:
|
|
86
|
+
adapters = resolve_engines(args.engines.split(",")) if args.engines else all_engines()
|
|
87
|
+
max_tokens = _parse_max_tokens(args.max_tokens)
|
|
88
|
+
|
|
89
|
+
hardware = detect_hardware()
|
|
90
|
+
if not args.json:
|
|
91
|
+
print(
|
|
92
|
+
f"Hardware: {hardware.cpu_model} ({hardware.platform}/{hardware.arch}), "
|
|
93
|
+
f"{hardware.total_memory_gb}GB\n"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
results = []
|
|
97
|
+
for adapter in adapters:
|
|
98
|
+
result = benchmark_engine(
|
|
99
|
+
adapter,
|
|
100
|
+
model=args.model,
|
|
101
|
+
max_tokens=max_tokens,
|
|
102
|
+
verbose=args.verbose,
|
|
103
|
+
on_progress=None if args.json else print,
|
|
104
|
+
)
|
|
105
|
+
results.append(result)
|
|
106
|
+
|
|
107
|
+
if not any(r.installed for r in results):
|
|
108
|
+
raise NoEnginesFoundError()
|
|
109
|
+
|
|
110
|
+
report = BenchmarkReport(
|
|
111
|
+
timestamp=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
112
|
+
hardware=hardware,
|
|
113
|
+
model=args.model,
|
|
114
|
+
engines=results,
|
|
115
|
+
recommendation=recommend(results),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if args.out:
|
|
119
|
+
write_json_report(report, args.out)
|
|
120
|
+
|
|
121
|
+
if args.json:
|
|
122
|
+
print(json_module.dumps(report_to_dict(report), indent=2))
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
print("\nResults:")
|
|
126
|
+
for r in results:
|
|
127
|
+
if not r.installed:
|
|
128
|
+
print(f" {r.engine}: not installed, skipped")
|
|
129
|
+
continue
|
|
130
|
+
if r.avg_tokens_per_second is None:
|
|
131
|
+
print(f" {r.engine}: FAILED ({r.error or 'no successful runs'})")
|
|
132
|
+
continue
|
|
133
|
+
n = sum(1 for run in r.runs if run.ok)
|
|
134
|
+
print(
|
|
135
|
+
f" {r.engine}: avg {r.avg_tokens_per_second} tok/s "
|
|
136
|
+
f"(range {r.min_tokens_per_second}-{r.max_tokens_per_second}, n={n})"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if report.recommendation:
|
|
140
|
+
print(f"\nRecommendation: {report.recommendation.engine} -- {report.recommendation.reason}")
|
|
141
|
+
|
|
142
|
+
if args.out:
|
|
143
|
+
print(f"\nFull report: {args.out}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def run_cli(argv: List[str]) -> int:
|
|
147
|
+
"""`argv` follows the sys.argv convention: argv[0] is the program name,
|
|
148
|
+
the real arguments start at argv[1]. Returns the process exit code (0
|
|
149
|
+
on a successful run with at least one engine tested; 1 on a usage error
|
|
150
|
+
or when no supported engine is installed). Invalid CLI input calls
|
|
151
|
+
sys.exit(2) directly via argparse, same as build_parser()'s own error
|
|
152
|
+
handling."""
|
|
153
|
+
parser = build_parser()
|
|
154
|
+
args = parser.parse_args(argv[1:])
|
|
155
|
+
|
|
156
|
+
if args.command != "run":
|
|
157
|
+
parser.print_help()
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
_run_command(args)
|
|
162
|
+
return 0
|
|
163
|
+
except (UsageError, NoEnginesFoundError) as err:
|
|
164
|
+
print(str(err), file=sys.stderr)
|
|
165
|
+
return 1
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def main() -> None:
|
|
169
|
+
sys.exit(run_cli(sys.argv))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__":
|
|
173
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ported from src/cost/cloud_comparison.ts.
|
|
3
|
+
|
|
4
|
+
Static, versioned pricing snapshot -- not a live API call. Prices drift;
|
|
5
|
+
this table is a directional reference, not a real-time quote. Update
|
|
6
|
+
PRICING_SNAPSHOT_DATE whenever the numbers below are refreshed.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Dict, Optional
|
|
12
|
+
|
|
13
|
+
PRICING_SNAPSHOT_DATE = "2026-07-15"
|
|
14
|
+
|
|
15
|
+
CLOUD_PRICING_PER_1K_OUTPUT_TOKENS: Dict[str, float] = {
|
|
16
|
+
"claude-5-haiku": 0.0008,
|
|
17
|
+
"claude-5-sonnet": 0.006,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class CostComparison:
|
|
23
|
+
cloud_model: str
|
|
24
|
+
cloud_cost_per_1k_tokens_usd: float
|
|
25
|
+
pricing_snapshot_date: str
|
|
26
|
+
note: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def compare_to_cloud(cloud_model: str) -> Optional[CostComparison]:
|
|
30
|
+
price = CLOUD_PRICING_PER_1K_OUTPUT_TOKENS.get(cloud_model)
|
|
31
|
+
if price is None:
|
|
32
|
+
return None
|
|
33
|
+
return CostComparison(
|
|
34
|
+
cloud_model=cloud_model,
|
|
35
|
+
cloud_cost_per_1k_tokens_usd=price,
|
|
36
|
+
pricing_snapshot_date=PRICING_SNAPSHOT_DATE,
|
|
37
|
+
note=(
|
|
38
|
+
"Local hardware's own amortized cost is not included -- this compares raw "
|
|
39
|
+
"generation cost only. Local inference has $0 marginal per-token cost once "
|
|
40
|
+
"hardware is already owned."
|
|
41
|
+
),
|
|
42
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ported from src/engines/llamacpp.ts. `model` is treated as a Hugging Face
|
|
3
|
+
repo spec (e.g. "bartowski/Qwen2.5-1.5B-Instruct-GGUF:Q4_K_M") -- llama.cpp's
|
|
4
|
+
own `-hf` flag downloads and caches it automatically, no manual step needed.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
|
|
10
|
+
from ..errors import EngineNotFoundError, UsageError
|
|
11
|
+
from ..harness.spawn_server import spawn_server_and_wait_ready
|
|
12
|
+
from ..types import StartedServer
|
|
13
|
+
|
|
14
|
+
_BINARY = "llama-server"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LlamaCppAdapter:
|
|
18
|
+
name = "llama.cpp"
|
|
19
|
+
binary = _BINARY
|
|
20
|
+
|
|
21
|
+
def is_installed(self) -> bool:
|
|
22
|
+
try:
|
|
23
|
+
subprocess.run( # noqa: S603, S607 -- fixed argv, binary name only, no shell
|
|
24
|
+
[_BINARY, "--version"],
|
|
25
|
+
stdout=subprocess.DEVNULL,
|
|
26
|
+
stderr=subprocess.DEVNULL,
|
|
27
|
+
check=True,
|
|
28
|
+
)
|
|
29
|
+
return True
|
|
30
|
+
except (OSError, subprocess.CalledProcessError):
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
def start_server(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
model: str,
|
|
37
|
+
port: int,
|
|
38
|
+
timeout_ms: int,
|
|
39
|
+
verbose: bool = False,
|
|
40
|
+
) -> StartedServer:
|
|
41
|
+
if not self.is_installed():
|
|
42
|
+
raise EngineNotFoundError(self.name, _BINARY)
|
|
43
|
+
if model.startswith("-"):
|
|
44
|
+
raise UsageError(
|
|
45
|
+
f'Invalid --model value "{model}": cannot start with "-" '
|
|
46
|
+
"(would be parsed as a flag by llama-server, not a model spec)"
|
|
47
|
+
)
|
|
48
|
+
spawned = spawn_server_and_wait_ready(
|
|
49
|
+
engine=self.name,
|
|
50
|
+
command=_BINARY,
|
|
51
|
+
args=["-hf", model, "--port", str(port), "--host", "127.0.0.1"],
|
|
52
|
+
ready_check_url=f"http://127.0.0.1:{port}/v1/models",
|
|
53
|
+
timeout_ms=timeout_ms,
|
|
54
|
+
verbose=verbose,
|
|
55
|
+
)
|
|
56
|
+
return StartedServer(
|
|
57
|
+
process=spawned.process,
|
|
58
|
+
base_url=f"http://127.0.0.1:{port}",
|
|
59
|
+
model_id="default",
|
|
60
|
+
stop=spawned.stop,
|
|
61
|
+
)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ported from src/engines/omlx.ts.
|
|
3
|
+
|
|
4
|
+
omlx has no CLI benchmark tool of its own (verified against its real
|
|
5
|
+
README -- its "Performance Benchmark" feature is a GUI-only, one-click
|
|
6
|
+
action in its admin dashboard). This adapter only starts the server; all
|
|
7
|
+
measurement goes through the shared HTTP harness in harness/measure.py,
|
|
8
|
+
same as every other engine.
|
|
9
|
+
|
|
10
|
+
`model` is treated as a model-directory subdirectory name under
|
|
11
|
+
~/.omlx/models/<model> -- omlx's `serve` command has no positional model
|
|
12
|
+
argument and discovers models from --model-dir subdirectories. Unlike
|
|
13
|
+
llama.cpp, omlx does not auto-download an arbitrary Hugging Face repo from
|
|
14
|
+
a CLI flag, so the model must already be present locally -- an honest v0.1
|
|
15
|
+
limitation, not hidden from the user.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import subprocess
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.request
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Optional
|
|
25
|
+
|
|
26
|
+
from ..errors import EngineNotFoundError, EngineStartTimeoutError
|
|
27
|
+
from ..harness.spawn_server import spawn_server_and_wait_ready
|
|
28
|
+
from ..types import StartedServer
|
|
29
|
+
|
|
30
|
+
_BINARY = "omlx"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OmlxAdapter:
|
|
34
|
+
name = "omlx"
|
|
35
|
+
binary = _BINARY
|
|
36
|
+
|
|
37
|
+
def is_installed(self) -> bool:
|
|
38
|
+
try:
|
|
39
|
+
subprocess.run( # noqa: S603, S607 -- fixed argv, binary name only, no shell
|
|
40
|
+
[_BINARY, "--version"],
|
|
41
|
+
stdout=subprocess.DEVNULL,
|
|
42
|
+
stderr=subprocess.DEVNULL,
|
|
43
|
+
check=True,
|
|
44
|
+
)
|
|
45
|
+
return True
|
|
46
|
+
except (OSError, subprocess.CalledProcessError):
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
def start_server(
|
|
50
|
+
self,
|
|
51
|
+
*,
|
|
52
|
+
model: str,
|
|
53
|
+
port: int,
|
|
54
|
+
timeout_ms: int,
|
|
55
|
+
verbose: bool = False,
|
|
56
|
+
) -> StartedServer:
|
|
57
|
+
if not self.is_installed():
|
|
58
|
+
raise EngineNotFoundError(self.name, _BINARY)
|
|
59
|
+
|
|
60
|
+
model_dir = str(Path.home() / ".omlx" / "models")
|
|
61
|
+
spawned = spawn_server_and_wait_ready(
|
|
62
|
+
engine=self.name,
|
|
63
|
+
command=_BINARY,
|
|
64
|
+
args=["serve", "--model-dir", model_dir, "--port", str(port)],
|
|
65
|
+
ready_check_url=f"http://127.0.0.1:{port}/v1/models",
|
|
66
|
+
timeout_ms=timeout_ms,
|
|
67
|
+
verbose=verbose,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
model_id = self._resolve_model_id(port, model)
|
|
71
|
+
if not model_id:
|
|
72
|
+
spawned.stop()
|
|
73
|
+
raise EngineStartTimeoutError(self.name, timeout_ms)
|
|
74
|
+
|
|
75
|
+
return StartedServer(
|
|
76
|
+
process=spawned.process,
|
|
77
|
+
base_url=f"http://127.0.0.1:{port}",
|
|
78
|
+
model_id=model_id,
|
|
79
|
+
stop=spawned.stop,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def _resolve_model_id(self, port: int, requested_model: str) -> Optional[str]:
|
|
83
|
+
try:
|
|
84
|
+
with urllib.request.urlopen( # noqa: S310 -- fixed, internally-constructed loopback URL
|
|
85
|
+
f"http://127.0.0.1:{port}/v1/models", timeout=5
|
|
86
|
+
) as response:
|
|
87
|
+
if response.status != 200:
|
|
88
|
+
return None
|
|
89
|
+
payload: Any = json.loads(response.read().decode("utf-8"))
|
|
90
|
+
except (urllib.error.URLError, TimeoutError, ValueError):
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
models = payload.get("data") if isinstance(payload, dict) else None
|
|
94
|
+
if not models:
|
|
95
|
+
return None
|
|
96
|
+
exact = next((m for m in models if m.get("id") == requested_model), None)
|
|
97
|
+
return exact["id"] if exact else models[0].get("id")
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Ported from src/engines/registry.ts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Callable, Dict, List
|
|
5
|
+
|
|
6
|
+
from ..errors import UsageError
|
|
7
|
+
from ..types import EngineAdapter
|
|
8
|
+
from .llamacpp import LlamaCppAdapter
|
|
9
|
+
from .omlx import OmlxAdapter
|
|
10
|
+
|
|
11
|
+
_ADAPTERS: Dict[str, Callable[[], EngineAdapter]] = {
|
|
12
|
+
"omlx": OmlxAdapter,
|
|
13
|
+
"llama.cpp": LlamaCppAdapter,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
SUPPORTED_ENGINES: List[str] = list(_ADAPTERS.keys())
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_engines(names: List[str]) -> List[EngineAdapter]:
|
|
20
|
+
"""Dedupes and validates a user-supplied --engines list."""
|
|
21
|
+
deduped = list(dict.fromkeys(names))
|
|
22
|
+
unknown = [n for n in deduped if n not in _ADAPTERS]
|
|
23
|
+
if unknown:
|
|
24
|
+
raise UsageError(
|
|
25
|
+
f"Unknown engine(s): {', '.join(unknown)}. Supported: {', '.join(SUPPORTED_ENGINES)}"
|
|
26
|
+
)
|
|
27
|
+
return [_ADAPTERS[n]() for n in deduped]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def all_engines() -> List[EngineAdapter]:
|
|
31
|
+
return [_ADAPTERS[n]() for n in SUPPORTED_ENGINES]
|
inferbench/errors.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exception hierarchy, ported 1:1 from src/errors.ts. Every error message is
|
|
3
|
+
kept identical in wording to the TypeScript original so the two CLIs report
|
|
4
|
+
the same failure text for the same underlying condition.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EngineNotFoundError(Exception):
|
|
10
|
+
def __init__(self, engine: str, binary: str) -> None:
|
|
11
|
+
self.engine = engine
|
|
12
|
+
self.binary = binary
|
|
13
|
+
super().__init__(f'{engine}: binary "{binary}" not found on PATH -- skipped')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EngineStartTimeoutError(Exception):
|
|
17
|
+
def __init__(self, engine: str, timeout_ms: int) -> None:
|
|
18
|
+
self.engine = engine
|
|
19
|
+
self.timeout_ms = timeout_ms
|
|
20
|
+
super().__init__(f"{engine}: server did not become ready within {timeout_ms}ms")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EngineRequestTimeoutError(Exception):
|
|
24
|
+
def __init__(self, engine: str, timeout_ms: int) -> None:
|
|
25
|
+
self.engine = engine
|
|
26
|
+
self.timeout_ms = timeout_ms
|
|
27
|
+
super().__init__(f"{engine}: request timed out after {timeout_ms}ms")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BenchmarkParseError(Exception):
|
|
31
|
+
def __init__(self, engine: str, raw_output: str) -> None:
|
|
32
|
+
self.engine = engine
|
|
33
|
+
self.raw_output = raw_output
|
|
34
|
+
super().__init__(f"{engine}: could not parse benchmark response")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NoEnginesFoundError(Exception):
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
super().__init__(
|
|
40
|
+
"No supported engines found on this machine. Install omlx (brew install omlx) "
|
|
41
|
+
"or llama.cpp (brew install llama.cpp) and try again."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class UsageError(Exception):
|
|
46
|
+
"""Raised for invalid CLI input recognized at the library layer (e.g. an
|
|
47
|
+
unknown --engines name). Distinct from argparse's own parse errors,
|
|
48
|
+
which exit 2 directly -- see cli.py."""
|
|
File without changes
|