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 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
+ ]
@@ -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