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
|
@@ -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: ...
|