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.
@@ -0,0 +1,109 @@
1
+ """
2
+ Hardware detection, ported from src/hardware/detect.ts. Node's `os` module
3
+ gives platform/arch/cpu-model/total-memory for free; Python's standard
4
+ library has no single equivalent, so this module composes `sys.platform`,
5
+ `platform.machine()`, POSIX `os.sysconf()`, and a couple of small,
6
+ fixed-argv subprocess calls (never a shell string, never user input) to
7
+ reach the same shape of data on macOS and Linux. Every value here degrades
8
+ to a safe fallback ("unknown" / 0.0) rather than raising, matching the
9
+ original's contract of always returning a usable HardwareProfile.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import platform
15
+ import subprocess
16
+ import sys
17
+
18
+ from ..types import HardwareProfile
19
+
20
+
21
+ def _detect_platform() -> str:
22
+ # Node's os.platform() and Python's sys.platform use the same tokens
23
+ # for the platforms this tool targets: "darwin", "linux", "win32".
24
+ return sys.platform
25
+
26
+
27
+ def _detect_arch() -> str:
28
+ machine = platform.machine().lower()
29
+ if machine in ("x86_64", "amd64"):
30
+ return "x64"
31
+ if machine == "aarch64":
32
+ return "arm64"
33
+ return machine or "unknown"
34
+
35
+
36
+ def _total_memory_gb() -> float:
37
+ try:
38
+ page_size = os.sysconf("SC_PAGE_SIZE")
39
+ phys_pages = os.sysconf("SC_PHYS_PAGES")
40
+ total_bytes = page_size * phys_pages
41
+ return round(total_bytes / 1024**3, 1)
42
+ except (ValueError, OSError, AttributeError):
43
+ pass
44
+ if sys.platform == "win32":
45
+ try:
46
+ import ctypes
47
+
48
+ class _MemoryStatusEx(ctypes.Structure):
49
+ _fields_ = [
50
+ ("dwLength", ctypes.c_ulong),
51
+ ("dwMemoryLoad", ctypes.c_ulong),
52
+ ("ullTotalPhys", ctypes.c_ulonglong),
53
+ ("ullAvailPhys", ctypes.c_ulonglong),
54
+ ("ullTotalPageFile", ctypes.c_ulonglong),
55
+ ("ullAvailPageFile", ctypes.c_ulonglong),
56
+ ("ullTotalVirtual", ctypes.c_ulonglong),
57
+ ("ullAvailVirtual", ctypes.c_ulonglong),
58
+ ("sullAvailExtendedVirtual", ctypes.c_ulonglong),
59
+ ]
60
+
61
+ stat = _MemoryStatusEx()
62
+ stat.dwLength = ctypes.sizeof(_MemoryStatusEx)
63
+ ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)) # type: ignore[attr-defined]
64
+ return round(stat.ullTotalPhys / 1024**3, 1)
65
+ except Exception: # noqa: BLE001 -- best-effort fallback, never fatal
66
+ pass
67
+ return 0.0
68
+
69
+
70
+ def _cpu_model() -> str:
71
+ if sys.platform == "darwin":
72
+ try:
73
+ result = subprocess.run(
74
+ ["sysctl", "-n", "machdep.cpu.brand_string"],
75
+ capture_output=True,
76
+ text=True,
77
+ timeout=2,
78
+ check=True,
79
+ )
80
+ model = result.stdout.strip()
81
+ if model:
82
+ return model
83
+ except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
84
+ pass
85
+ elif sys.platform.startswith("linux"):
86
+ try:
87
+ with open("/proc/cpuinfo", encoding="utf-8") as handle:
88
+ for line in handle:
89
+ if line.lower().startswith("model name"):
90
+ _, _, value = line.partition(":")
91
+ model = value.strip()
92
+ if model:
93
+ return model
94
+ except OSError:
95
+ pass
96
+ processor = platform.processor()
97
+ return processor if processor else "unknown"
98
+
99
+
100
+ def detect_hardware() -> HardwareProfile:
101
+ plat = _detect_platform()
102
+ arch = _detect_arch()
103
+ return HardwareProfile(
104
+ platform=plat,
105
+ arch=arch,
106
+ total_memory_gb=_total_memory_gb(),
107
+ cpu_model=_cpu_model(),
108
+ is_apple_silicon=(plat == "darwin" and arch == "arm64"),
109
+ )
File without changes
@@ -0,0 +1,103 @@
1
+ """
2
+ Ported from src/harness/measure.ts. Sends one timed chat-completion request
3
+ to an engine's OpenAI-compatible server and measures wall-clock time plus
4
+ reported token counts -- the ONE shared measurement code path for every
5
+ engine (the architecture decision that replaced per-engine benchmark-CLI
6
+ parsing; omlx has no CLI benchmark tool at all, verified against its real
7
+ README before the original tool's adapter code was written).
8
+
9
+ Elapsed time is measured across the FULL response body, not just the
10
+ headers: urlopen() returns once headers arrive, before the response body
11
+ (and therefore generation) has finished streaming. The original TypeScript
12
+ harness had a real bug here once -- measuring right after `await fetch(...)`
13
+ resolved, which produced a physically impossible 64,646 tok/s during a live
14
+ end-to-end run before it was caught and fixed. This port measures after
15
+ `response.read()` completes, deliberately not reintroducing that bug.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import socket
21
+ import time
22
+ import urllib.error
23
+ import urllib.request
24
+ from dataclasses import dataclass
25
+
26
+ from ..errors import BenchmarkParseError, EngineRequestTimeoutError
27
+ from ..types import CompletionResult
28
+
29
+
30
+ @dataclass
31
+ class TimedCompletionOptions:
32
+ engine: str
33
+ base_url: str
34
+ model_id: str
35
+ prompt: str
36
+ max_tokens: int
37
+ timeout_ms: int
38
+
39
+
40
+ def timed_completion(opts: TimedCompletionOptions) -> CompletionResult:
41
+ body = json.dumps(
42
+ {
43
+ "model": opts.model_id,
44
+ "messages": [{"role": "user", "content": opts.prompt}],
45
+ "max_tokens": opts.max_tokens,
46
+ "stream": False,
47
+ }
48
+ ).encode("utf-8")
49
+
50
+ request = urllib.request.Request(
51
+ f"{opts.base_url}/v1/chat/completions",
52
+ data=body,
53
+ headers={"Content-Type": "application/json"},
54
+ method="POST",
55
+ )
56
+
57
+ start = time.perf_counter()
58
+ try:
59
+ response = urllib.request.urlopen( # noqa: S310 -- fixed, internally-constructed URL only
60
+ request, timeout=opts.timeout_ms / 1000
61
+ )
62
+ raw_bytes = response.read()
63
+ except urllib.error.HTTPError as err:
64
+ error_body = err.read().decode("utf-8", errors="replace") if err.fp else ""
65
+ raise BenchmarkParseError(opts.engine, f"HTTP {err.code}: {error_body}") from err
66
+ except (socket.timeout, TimeoutError) as err:
67
+ raise EngineRequestTimeoutError(opts.engine, opts.timeout_ms) from err
68
+ except urllib.error.URLError as err:
69
+ if isinstance(err.reason, (socket.timeout, TimeoutError)):
70
+ raise EngineRequestTimeoutError(opts.engine, opts.timeout_ms) from err
71
+ raise
72
+
73
+ elapsed_ms = (time.perf_counter() - start) * 1000
74
+ raw_text = raw_bytes.decode("utf-8", errors="replace")
75
+
76
+ try:
77
+ payload = json.loads(raw_text)
78
+ except json.JSONDecodeError as err:
79
+ raise BenchmarkParseError(opts.engine, raw_text) from err
80
+
81
+ usage = payload.get("usage") if isinstance(payload, dict) else None
82
+ completion_tokens = usage.get("completion_tokens") if isinstance(usage, dict) else None
83
+ if not isinstance(completion_tokens, (int, float)) or isinstance(completion_tokens, bool):
84
+ completion_tokens = None
85
+
86
+ prompt_tokens_details = usage.get("prompt_tokens_details") if isinstance(usage, dict) else None
87
+ cached_raw = (
88
+ prompt_tokens_details.get("cached_tokens") if isinstance(prompt_tokens_details, dict) else None
89
+ )
90
+ cached_prompt_tokens = (
91
+ int(cached_raw) if isinstance(cached_raw, (int, float)) and not isinstance(cached_raw, bool) else 0
92
+ )
93
+
94
+ tokens_per_second = (
95
+ completion_tokens / (elapsed_ms / 1000) if completion_tokens and elapsed_ms > 0 else None
96
+ )
97
+
98
+ return CompletionResult(
99
+ elapsed_ms=round(elapsed_ms),
100
+ completion_tokens=int(completion_tokens) if completion_tokens is not None else None,
101
+ cached_prompt_tokens=cached_prompt_tokens,
102
+ tokens_per_second=round(tokens_per_second, 2) if tokens_per_second is not None else None,
103
+ )
@@ -0,0 +1,69 @@
1
+ """
2
+ Ported from src/harness/spawn-server.ts. Spawns a long-running engine
3
+ server process and polls a URL until it responds, so callers get a server
4
+ that is actually ready rather than racing against startup.
5
+
6
+ Security note: `command` and `args` are always a fixed argv list passed to
7
+ subprocess.Popen with no shell involved (no shell=True anywhere in this
8
+ module or its callers) -- the model spec a user passes on --model becomes
9
+ a single argv element, never string-concatenated into a shell command, so
10
+ there is no command-injection path through it. This mirrors the same
11
+ argv-array discipline the original TypeScript harness documents in its own
12
+ comments.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import subprocess
17
+ import time
18
+ import urllib.error
19
+ import urllib.request
20
+ from dataclasses import dataclass
21
+ from typing import Callable, List, Optional
22
+
23
+ from ..errors import EngineStartTimeoutError
24
+
25
+
26
+ @dataclass
27
+ class SpawnedServer:
28
+ process: subprocess.Popen
29
+ stop: Callable[[], None]
30
+
31
+
32
+ def spawn_server_and_wait_ready(
33
+ *,
34
+ engine: str,
35
+ command: str,
36
+ args: List[str],
37
+ ready_check_url: str,
38
+ timeout_ms: int,
39
+ poll_interval_ms: int = 500,
40
+ verbose: bool = False,
41
+ ) -> SpawnedServer:
42
+ stdio = None if verbose else subprocess.DEVNULL
43
+ process = subprocess.Popen([command, *args], stdout=stdio, stderr=stdio) # noqa: S603 -- fixed argv, no shell
44
+
45
+ def stop() -> None:
46
+ if process.poll() is None:
47
+ process.kill()
48
+
49
+ deadline = time.monotonic() + timeout_ms / 1000
50
+ last_error: Optional[BaseException] = None
51
+
52
+ while time.monotonic() < deadline:
53
+ if process.poll() is not None:
54
+ raise RuntimeError(
55
+ f"{engine}: process exited early (code {process.returncode}) before becoming ready"
56
+ )
57
+ try:
58
+ with urllib.request.urlopen(ready_check_url, timeout=2) as response: # noqa: S310
59
+ if 200 <= response.status < 300:
60
+ return SpawnedServer(process=process, stop=stop)
61
+ except Exception as err: # noqa: BLE001 -- polling loop; any failure just means "not ready yet"
62
+ last_error = err
63
+ time.sleep(poll_interval_ms / 1000)
64
+
65
+ stop()
66
+ timeout_err = EngineStartTimeoutError(engine, timeout_ms)
67
+ if last_error is not None:
68
+ timeout_err.__cause__ = last_error
69
+ raise timeout_err
inferbench/prompts.py ADDED
@@ -0,0 +1,22 @@
1
+ """
2
+ Default varied prompt set, ported verbatim from src/prompts.ts: 8 distinct
3
+ prompts across different topics and lengths, deliberately never repeating
4
+ the exact same prompt, to avoid the prefix-cache skew observed during the
5
+ original TypeScript tool's real validation (a repeated identical prompt let
6
+ llama.cpp reuse 39/40 cached prompt tokens on the second call, which is not
7
+ a fair steady-state comparison).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ DEFAULT_PROMPTS: list[str] = [
12
+ "Explain in one paragraph why the sky is blue.",
13
+ "Write a short haiku about autumn leaves falling in a quiet forest.",
14
+ "What are three practical tips for someone learning to cook rice perfectly every time?",
15
+ "Summarize the plot of a story about a lighthouse keeper who discovers a message in a bottle.",
16
+ "List five differences between a cat and a dog as household pets.",
17
+ "Describe how a bicycle chain transfers power from the pedals to the rear wheel.",
18
+ "Give a brief explanation of why leaves change color in autumn, covering chlorophyll and other pigments.",
19
+ "What is the difference between weather and climate? Explain with a simple example.",
20
+ ]
21
+
22
+ WARMUP_PROMPT = "Say hello."
inferbench/py.typed ADDED
File without changes
File without changes
@@ -0,0 +1,29 @@
1
+ """
2
+ Ported from src/recommend/config.ts.
3
+
4
+ v0.1 recommendation rule: highest measured average tok/s among engines that
5
+ were actually installed and tested. Deliberately simple -- richer
6
+ multi-factor scoring (memory, cost) is intentionally deferred until real
7
+ usage shows this simple rule picks wrong recommendations.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import List, Optional
12
+
13
+ from ..types import EngineBenchmarkResult, Recommendation
14
+
15
+
16
+ def recommend(results: List[EngineBenchmarkResult]) -> Optional[Recommendation]:
17
+ candidates = [r for r in results if r.installed and r.avg_tokens_per_second is not None]
18
+ if not candidates:
19
+ return None
20
+
21
+ best = max(candidates, key=lambda r: r.avg_tokens_per_second or 0)
22
+
23
+ return Recommendation(
24
+ engine=best.engine,
25
+ reason=(
26
+ f"highest measured throughput on this run ({best.avg_tokens_per_second} tok/s avg) "
27
+ "-- specific to this hardware and model, not a universal ranking"
28
+ ),
29
+ )
File without changes
@@ -0,0 +1,66 @@
1
+ """
2
+ Ported from src/report/json.ts, extended with a snake_case -> camelCase
3
+ key conversion so a report written by this CLI's --out is field-for-field
4
+ compatible with the npm CLI's own JSON report (both usable by the same
5
+ downstream tooling) -- see docs/concepts.md.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from dataclasses import asdict
12
+ from typing import Any, Dict
13
+
14
+ from ..types import BenchmarkReport
15
+
16
+
17
+ class UnsafeOutputPathError(ValueError):
18
+ pass
19
+
20
+
21
+ def _to_camel_case(key: str) -> str:
22
+ head, *tail = key.split("_")
23
+ return head + "".join(part.title() for part in tail)
24
+
25
+
26
+ def _camelize(value: Any) -> Any:
27
+ if isinstance(value, dict):
28
+ return {_to_camel_case(k): _camelize(v) for k, v in value.items()}
29
+ if isinstance(value, list):
30
+ return [_camelize(v) for v in value]
31
+ return value
32
+
33
+
34
+ def report_to_dict(report: BenchmarkReport) -> Dict[str, Any]:
35
+ """BenchmarkReport never holds a StartedServer (that only lives on the
36
+ adapter's return value during a run, not in the final report), so a
37
+ plain dataclasses.asdict() is safe here -- nothing unconvertible in the
38
+ tree."""
39
+ return _camelize(asdict(report))
40
+
41
+
42
+ # --out is a plain CLI flag today, but this CLI is also meant to be invoked
43
+ # programmatically by agents that may derive the value from less-trusted
44
+ # input (a fetched benchmark config, an LLM-generated argument list, etc).
45
+ # A relative path containing ".." segments can escape the intended output
46
+ # location entirely (--out ../../../etc/cron.d/x) -- reject any --out value
47
+ # that resolves outside the current working directory. An explicit absolute
48
+ # path is still allowed: that's a value the caller typed/passed directly,
49
+ # not one that silently escaped via traversal.
50
+ def _assert_safe_output_path(file_path: str) -> None:
51
+ if os.path.isabs(file_path):
52
+ return
53
+ cwd = os.path.realpath(os.getcwd())
54
+ resolved = os.path.realpath(os.path.join(cwd, file_path))
55
+ if resolved != cwd and not resolved.startswith(cwd + os.sep):
56
+ raise UnsafeOutputPathError(
57
+ f'--out "{file_path}" resolves outside the current working directory '
58
+ f"({resolved}). Pass an absolute path if you intend to write outside "
59
+ "the working directory."
60
+ )
61
+
62
+
63
+ def write_json_report(report: BenchmarkReport, file_path: str) -> None:
64
+ _assert_safe_output_path(file_path)
65
+ with open(file_path, "w", encoding="utf-8") as handle:
66
+ json.dump(report_to_dict(report), handle, indent=2)
inferbench/types.py ADDED
@@ -0,0 +1,95 @@
1
+ """
2
+ Shared data types, ported from src/types.ts. Field names follow Python
3
+ convention (snake_case) rather than the TypeScript originals' camelCase --
4
+ the JSON report writer (report/json_report.py) converts back to camelCase
5
+ so a --json/--out report is field-for-field compatible with the npm CLI's
6
+ own JSON output, even though the two libraries' native call shapes differ.
7
+ See docs/concepts.md for the full data model and the snake_case/camelCase
8
+ note.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import subprocess
13
+ from dataclasses import dataclass, field
14
+ from typing import Callable, List, Optional, Protocol, runtime_checkable
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class HardwareProfile:
19
+ platform: str
20
+ arch: str
21
+ total_memory_gb: float
22
+ cpu_model: str
23
+ is_apple_silicon: bool
24
+
25
+
26
+ @dataclass
27
+ class StartedServer:
28
+ process: subprocess.Popen
29
+ base_url: str
30
+ model_id: str
31
+ stop: Callable[[], None]
32
+
33
+
34
+ @dataclass
35
+ class CompletionResult:
36
+ elapsed_ms: float
37
+ completion_tokens: Optional[int]
38
+ cached_prompt_tokens: int
39
+ tokens_per_second: Optional[float]
40
+
41
+
42
+ @dataclass
43
+ class PromptRunResult:
44
+ prompt: str
45
+ ok: bool
46
+ error: Optional[str] = None
47
+ result: Optional[CompletionResult] = None
48
+
49
+
50
+ @dataclass
51
+ class EngineBenchmarkResult:
52
+ engine: str
53
+ installed: bool
54
+ error: Optional[str] = None
55
+ runs: List[PromptRunResult] = field(default_factory=list)
56
+ avg_tokens_per_second: Optional[float] = None
57
+ min_tokens_per_second: Optional[float] = None
58
+ max_tokens_per_second: Optional[float] = None
59
+
60
+
61
+ @dataclass
62
+ class Recommendation:
63
+ engine: str
64
+ reason: str
65
+
66
+
67
+ @dataclass
68
+ class BenchmarkReport:
69
+ timestamp: str
70
+ hardware: HardwareProfile
71
+ model: str
72
+ engines: List[EngineBenchmarkResult]
73
+ recommendation: Optional[Recommendation]
74
+
75
+
76
+ @runtime_checkable
77
+ class EngineAdapter(Protocol):
78
+ """Structural type -- any object with this shape (name, binary,
79
+ is_installed(), start_server(...)) works as an adapter, mirroring the
80
+ TS interface's duck-typed usage. LlamaCppAdapter and OmlxAdapter both
81
+ satisfy this without inheriting from it explicitly."""
82
+
83
+ name: str
84
+ binary: str
85
+
86
+ def is_installed(self) -> bool: ...
87
+
88
+ def start_server(
89
+ self,
90
+ *,
91
+ model: str,
92
+ port: int,
93
+ timeout_ms: int,
94
+ verbose: bool = False,
95
+ ) -> StartedServer: ...