perferox 0.1.0__tar.gz

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,72 @@
1
+ Metadata-Version: 2.4
2
+ Name: perferox
3
+ Version: 0.1.0
4
+ Summary: Agentic benchmark explorer for SGLang.
5
+ Author: Naman Chetwani
6
+ Keywords: sglang,benchmark,fuzzing,inference,agents
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: langchain
17
+ Requires-Dist: langgraph
18
+ Requires-Dist: langchain-openai
19
+ Requires-Dist: textual
20
+ Requires-Dist: paramiko
21
+ Requires-Dist: pydantic
22
+ Requires-Dist: numpy
23
+ Requires-Dist: sentence-transformers>=5.6.0
24
+
25
+ <h1 align="center">perferox</h1>
26
+
27
+ Perferox is a small agentic benchmark explorer for inference engines and ML
28
+ systems performance. The first target is SGLang.
29
+
30
+ This is alpha software. It is not ready for common use yet. Expect rough edges,
31
+ missing docs, changing APIs, and places where you still need to understand the
32
+ repo before trusting a run.
33
+
34
+ The shape is intentionally simple: give an agent a bounded objective, let it
35
+ delegate benchmark workers, run experiments through narrow tools, and save the
36
+ parts worth keeping. The point is not to make a giant benchmark platform. The
37
+ point is to make it easy to poke at weird corners of an inference stack without
38
+ losing track of what already happened.
39
+
40
+ The first use case is SGLang serving benchmarks. Perferox is meant to try
41
+ different models, backends, cache settings, request patterns, chips, and setup
42
+ conditions, then preserve the useful results in SQLite. It records the normal
43
+ perf signals, like request throughput, token throughput, TTFT, TPOT, cache hit
44
+ rate, accept length, and error rate. It also has a place to store human-readable
45
+ anomalies, because a lot of the interesting bugs are not just "number went down"
46
+ but "this specific setup behaved strangely in a way a person should inspect."
47
+
48
+ The important constraint is that the host owns state. Agents do not choose run
49
+ IDs, write SQLite directly, or decide whether a benchmark should bypass caps.
50
+ Real experiments go through a benchmark tool. Started runs get logged. Failed
51
+ runs still count once they actually start. The database is the source of truth
52
+ for runs, experiments, anomalies, trace references, and compact exploration
53
+ state.
54
+
55
+ Current pieces:
56
+
57
+ - a `perferox` CLI
58
+ - a Textual TUI for starting and stopping exploration
59
+ - a LangGraph main coordinator
60
+ - tmux-wrapped benchmark subagents
61
+ - Paramiko remote sessions
62
+ - structured SGLang `bench_serving` command construction and parsing
63
+ - SQLite-backed runs, experiments, anomalies, notifications, and ExplorerState
64
+ - local SGLang docs search plumbing
65
+
66
+ This is still early software. The goal is to keep the code small enough that it
67
+ stays easy to inspect and change. The preferred path is boring Python, explicit
68
+ state, small tools, and very few abstractions.
69
+
70
+ ```bash
71
+ uvx perferox --help
72
+ ```
@@ -0,0 +1,48 @@
1
+ <h1 align="center">perferox</h1>
2
+
3
+ Perferox is a small agentic benchmark explorer for inference engines and ML
4
+ systems performance. The first target is SGLang.
5
+
6
+ This is alpha software. It is not ready for common use yet. Expect rough edges,
7
+ missing docs, changing APIs, and places where you still need to understand the
8
+ repo before trusting a run.
9
+
10
+ The shape is intentionally simple: give an agent a bounded objective, let it
11
+ delegate benchmark workers, run experiments through narrow tools, and save the
12
+ parts worth keeping. The point is not to make a giant benchmark platform. The
13
+ point is to make it easy to poke at weird corners of an inference stack without
14
+ losing track of what already happened.
15
+
16
+ The first use case is SGLang serving benchmarks. Perferox is meant to try
17
+ different models, backends, cache settings, request patterns, chips, and setup
18
+ conditions, then preserve the useful results in SQLite. It records the normal
19
+ perf signals, like request throughput, token throughput, TTFT, TPOT, cache hit
20
+ rate, accept length, and error rate. It also has a place to store human-readable
21
+ anomalies, because a lot of the interesting bugs are not just "number went down"
22
+ but "this specific setup behaved strangely in a way a person should inspect."
23
+
24
+ The important constraint is that the host owns state. Agents do not choose run
25
+ IDs, write SQLite directly, or decide whether a benchmark should bypass caps.
26
+ Real experiments go through a benchmark tool. Started runs get logged. Failed
27
+ runs still count once they actually start. The database is the source of truth
28
+ for runs, experiments, anomalies, trace references, and compact exploration
29
+ state.
30
+
31
+ Current pieces:
32
+
33
+ - a `perferox` CLI
34
+ - a Textual TUI for starting and stopping exploration
35
+ - a LangGraph main coordinator
36
+ - tmux-wrapped benchmark subagents
37
+ - Paramiko remote sessions
38
+ - structured SGLang `bench_serving` command construction and parsing
39
+ - SQLite-backed runs, experiments, anomalies, notifications, and ExplorerState
40
+ - local SGLang docs search plumbing
41
+
42
+ This is still early software. The goal is to keep the code small enough that it
43
+ stays easy to inspect and change. The preferred path is boring Python, explicit
44
+ state, small tools, and very few abstractions.
45
+
46
+ ```bash
47
+ uvx perferox --help
48
+ ```
@@ -0,0 +1 @@
1
+ """Perferox package."""
@@ -0,0 +1,158 @@
1
+ """Tiny CLI for tmux-wrapped Perferox agent processes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import shlex
7
+ import shutil
8
+ import subprocess
9
+ import time
10
+ from contextlib import closing
11
+ from pathlib import Path
12
+
13
+ from langchain_core.messages import HumanMessage
14
+
15
+ from perferox import db
16
+ from perferox.auth import build_chat_model
17
+ from perferox.main_agent import build_main_agent_graph
18
+ from perferox.remote import SessionRegistry
19
+ from perferox.subagent import build_subagent_graph, stream_with_trace
20
+
21
+ MAIN_SESSION = "perferox-main"
22
+
23
+
24
+ def main(argv: list[str] | None = None) -> int:
25
+ """Parse the runner command and run the requested whole-agent process."""
26
+ parser = argparse.ArgumentParser(prog="python -m perferox.agent_runner")
27
+ subparsers = parser.add_subparsers(dest="command", required=True)
28
+ for name in ("launch-main", "main"):
29
+ subparser = subparsers.add_parser(name)
30
+ subparser.add_argument("--db-path", required=True)
31
+ subparser.add_argument("--trace-dir", default="traces")
32
+ subparser.add_argument("--objective", required=True)
33
+ subparser.add_argument("--cwd", default=".")
34
+ subparsers.choices["main"].add_argument("--poll-s", type=float, default=5.0)
35
+ subagent = subparsers.add_parser("subagent")
36
+ for name in ("agent-id", "db-path", "trace-path", "goal-file", "attempt-cap"):
37
+ subagent.add_argument(f"--{name}", required=True)
38
+ args = parser.parse_args(argv)
39
+
40
+ if args.command == "launch-main":
41
+ tmux = shutil.which("tmux")
42
+ if tmux is None:
43
+ print("tmux is not installed or not on PATH")
44
+ return 1
45
+ cwd = Path(args.cwd).resolve()
46
+ db_path = (cwd / args.db_path).resolve()
47
+ trace_dir = (cwd / args.trace_dir).resolve()
48
+ trace_dir.mkdir(parents=True, exist_ok=True)
49
+ command = shlex.join(["uv", "run", "python", "-m", "perferox.agent_runner", "main", "--db-path", str(db_path), "--trace-dir", str(trace_dir), "--objective", args.objective, "--cwd", str(cwd)])
50
+ result = subprocess.run([tmux, "new-session", "-d", "-s", MAIN_SESSION, "-c", str(cwd), "--", "bash", "-lc", command], text=True, capture_output=True, check=False)
51
+ print(f"started {MAIN_SESSION}; attach with: tmux attach -t {MAIN_SESSION}" if result.returncode == 0 else (result.stderr or result.stdout).strip())
52
+ return result.returncode
53
+
54
+ if args.command == "main":
55
+ cwd = Path(args.cwd).resolve()
56
+ db_path = (cwd / args.db_path).resolve()
57
+ trace_dir = (cwd / args.trace_dir).resolve()
58
+ trace_dir.mkdir(parents=True, exist_ok=True)
59
+ trace_path = trace_dir / f"{MAIN_SESSION}-{int(time.time())}.jsonl"
60
+ try:
61
+ with closing(db.connect(db_path)) as conn:
62
+ db.init_db(conn)
63
+ db.record_agent_session(conn, session_name=MAIN_SESSION, role="main", trace_ref=str(trace_path))
64
+ graph = build_main_agent_graph(build_chat_model(), db_path, cwd=cwd, trace_dir=trace_dir)
65
+ state = {"objective": args.objective, "messages": [HumanMessage(content=args.objective)]}
66
+ while True:
67
+ for event in stream_with_trace(graph, state, trace_path):
68
+ state = _collect_update(state, event)
69
+ update = _wait_for_main_event(db_path, args.poll_s)
70
+ if update is None:
71
+ return 0
72
+ state["messages"] = [*state.get("messages", []), HumanMessage(content=update)]
73
+ finally:
74
+ with closing(db.connect(db_path)) as conn:
75
+ db.finish_agent_session(conn, session_name=MAIN_SESSION, status="exited")
76
+
77
+ agent_id = int(args.agent_id)
78
+ db_path = Path(args.db_path).resolve()
79
+ trace_path = Path(args.trace_path).resolve()
80
+ session_name = f"perferox-agent-{agent_id}"
81
+ registry = SessionRegistry()
82
+ try:
83
+ with closing(db.connect(db_path)) as conn:
84
+ db.init_db(conn)
85
+ db.record_agent_session(conn, session_name=session_name, role="subagent", agent_id=agent_id, trace_ref=str(trace_path))
86
+ attempt_cap = int(args.attempt_cap)
87
+ graph = build_subagent_graph(build_chat_model(), agent_id, registry, db_path, attempt_cap=attempt_cap, trace_ref=str(trace_path))
88
+ state = {"agent_id": agent_id, "messages": [HumanMessage(content=Path(args.goal_file).read_text(encoding="utf-8"))]}
89
+ for _ in stream_with_trace(graph, state, trace_path):
90
+ pass
91
+ return 0
92
+ finally:
93
+ registry.close(f"agent-{agent_id}")
94
+ with closing(db.connect(db_path)) as conn:
95
+ db.init_db(conn)
96
+ if db.finish_agent_session(conn, session_name=session_name, status="exited"):
97
+ db.append_explorer_state(conn, agent_id=agent_id, line=f"agent-{agent_id} tmux exited; trace {trace_path.name}")
98
+
99
+
100
+ def _collect_update(state: dict, event: object) -> dict:
101
+ """Merge one streamed LangGraph update into a reusable graph state."""
102
+ if not isinstance(event, dict):
103
+ return state
104
+ for update in event.values():
105
+ if not isinstance(update, dict):
106
+ continue
107
+ for key, value in update.items():
108
+ if key == "messages":
109
+ state.setdefault(key, []).extend(value)
110
+ else:
111
+ state[key] = value
112
+ return state
113
+
114
+
115
+ def _wait_for_main_event(db_path: Path, poll_s: float) -> str | None:
116
+ """Wait for a main-agent wakeup or a human End request."""
117
+ previous_running = None
118
+ while True:
119
+ with closing(db.connect(db_path)) as conn:
120
+ db.init_db(conn)
121
+ main_row = conn.execute("SELECT status FROM agent_sessions WHERE session_name = ?", (MAIN_SESSION,)).fetchone()
122
+ notifications = db.take_main_notifications(conn)
123
+ if notifications:
124
+ lines = ["Subagent SQLite write notifications:"]
125
+ for row in notifications:
126
+ prefix = "ANOMALY " if row["kind"] == "anomaly_logged" else ""
127
+ lines.append(
128
+ f"{prefix}notification_id={row['notification_id']} kind={row['kind']} "
129
+ f"agent_id={row['agent_id']} run_id={row['run_id']} table={row['table_name']}"
130
+ )
131
+ lines.append(row["row_json"])
132
+ return "\n".join(lines)
133
+ tmux = shutil.which("tmux")
134
+ active_query = "SELECT session_name, agent_id, trace_ref FROM agent_sessions WHERE status IN ('running', 'ending') AND role = 'subagent'"
135
+ rows = conn.execute(active_query).fetchall()
136
+ for row in rows:
137
+ alive = tmux and subprocess.run([tmux, "has-session", "-t", row["session_name"]], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False).returncode == 0
138
+ if not alive and db.finish_agent_session(conn, session_name=row["session_name"], status="missing"):
139
+ line = f"agent-{row['agent_id']} tmux missing; trace {Path(row['trace_ref']).name}"
140
+ db.append_explorer_state(conn, agent_id=row["agent_id"], line=line)
141
+ return f"Tmux session update:\n{line}"
142
+ if main_row is not None and main_row["status"] == "ending":
143
+ if not rows:
144
+ return None
145
+ time.sleep(poll_s)
146
+ return "End requested. Do not delegate new subagents; wait for active subagents to wrap up, then summarize."
147
+ running = {row["session_name"] for row in rows}
148
+ if previous_running is not None and running != previous_running:
149
+ return "Tmux session update:\nsubagent tmux sessions changed" if running else "Tmux session update:\nall subagent tmux sessions completed"
150
+ if not running:
151
+ time.sleep(poll_s)
152
+ return "No active subagents are running. Continue exploration from the objective and ExplorerState."
153
+ previous_running = running
154
+ time.sleep(poll_s)
155
+
156
+
157
+ if __name__ == "__main__":
158
+ raise SystemExit(main())
@@ -0,0 +1,46 @@
1
+ """ChatGPT OAuth auth and model construction for Perferox."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from langchain_core.language_models.chat_models import BaseChatModel
10
+
11
+ DEFAULT_CHAT_MODEL = "gpt-5.5"
12
+ MODEL_ENV_VAR = "PERFEROX_CHAT_MODEL"
13
+ ORIGINATOR = "perferox"
14
+
15
+
16
+ def chatgpt_auth_ready() -> bool:
17
+ """Return whether a refreshable ChatGPT OAuth token is available."""
18
+ try:
19
+ from langchain_openai.chatgpt_oauth import _FileChatGPTOAuthTokenProvider
20
+
21
+ _FileChatGPTOAuthTokenProvider.from_default_store().get_token()
22
+ except Exception: # noqa: BLE001
23
+ return False
24
+ return True
25
+
26
+
27
+ def login_chatgpt_oauth(timeout_s: float = 300.0) -> None:
28
+ """Run the browser-based ChatGPT OAuth login flow."""
29
+ from langchain_openai.chatgpt_oauth import login_chatgpt
30
+
31
+ login_chatgpt(timeout=timeout_s, open_browser=True)
32
+
33
+
34
+ def build_chat_model(model: str | None = None) -> BaseChatModel:
35
+ """Build Perferox's OAuth-backed LangChain chat model."""
36
+ from langchain_openai.chat_models.codex import _ChatOpenAICodex
37
+ from langchain_openai.chatgpt_oauth import _FileChatGPTOAuthTokenProvider
38
+
39
+ provider = _FileChatGPTOAuthTokenProvider.from_default_store()
40
+ provider.get_token()
41
+ model_name = model or os.environ.get(MODEL_ENV_VAR, DEFAULT_CHAT_MODEL)
42
+ return _ChatOpenAICodex(
43
+ model=model_name,
44
+ originator=ORIGINATOR,
45
+ token_provider=provider,
46
+ )
@@ -0,0 +1,114 @@
1
+ """SGLang benchmark command builders and parsers."""
2
+
3
+ import json
4
+ from typing import Any, Self
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
7
+
8
+ BENCH_TIMEOUT_S = 6 * 60 * 60.0
9
+ _CONSOLE_METRIC_LABELS = {
10
+ "Request throughput (req/s)": "request_rps",
11
+ "Input token throughput (tok/s)": "input_tps",
12
+ "Output token throughput (tok/s)": "output_tps",
13
+ "Median TTFT (ms)": "ttft_p50_ms",
14
+ "P99 TTFT (ms)": "ttft_p99_ms",
15
+ "Median TPOT (ms)": "tpot_p50_ms",
16
+ "P99 TPOT (ms)": "tpot_p99_ms",
17
+ "Cache hit rate": "cache_hit_rate",
18
+ "Accept length": "accept_length",
19
+ }
20
+
21
+ class BenchServingArgs(BaseModel):
22
+ """Typed common inputs plus raw pass-through flags for SGLang's CLI."""
23
+
24
+ model_config = ConfigDict(extra="forbid")
25
+
26
+ backend: str = "sglang"
27
+ base_url: str | None = None
28
+ host: str | None = None
29
+ port: int | None = Field(None, ge=1, le=65535)
30
+ ready_check_timeout_sec: int | None = Field(None, ge=0)
31
+ dataset_name: str = "sharegpt"
32
+ dataset_path: str | None = None
33
+ model: str | None = None
34
+ served_model_name: str | None = None
35
+ tokenizer: str | None = None
36
+ num_prompts: int | None = Field(None, ge=1)
37
+ sharegpt_output_len: int | None = Field(None, ge=4)
38
+ sharegpt_context_len: int | None = Field(None, ge=1)
39
+ random_input_len: int | None = Field(None, ge=1)
40
+ random_output_len: int | None = Field(None, ge=0)
41
+ request_rate: float | None = Field(None, gt=0)
42
+ max_concurrency: int | None = Field(None, ge=0)
43
+ output_file: str | None = None
44
+ output_details: bool = False
45
+ print_requests: bool = False
46
+ disable_tqdm: bool = False
47
+ disable_stream: bool = False
48
+ cache_report: bool = False
49
+ seed: int | None = Field(None, ge=0, le=4294967295)
50
+ disable_ignore_eos: bool = False
51
+ temperature: float | None = Field(None, ge=0)
52
+ top_p: float | None = Field(None, gt=0, le=1)
53
+ extra_request_body: dict[str, Any] | None = None
54
+ apply_chat_template: bool = False
55
+ flush_cache: bool = False
56
+ warmup_requests: int | None = Field(None, ge=0)
57
+ tokenize_prompt: bool = False
58
+ header: dict[str, str] | None = None
59
+ extra_args: list[str] = Field(default_factory=list)
60
+ timeout_s: float | None = Field(BENCH_TIMEOUT_S, gt=0)
61
+
62
+ @model_validator(mode="after")
63
+ def check_serving_constraints(self) -> Self:
64
+ """Mirror bench_serving's parse-time and early runtime argument checks."""
65
+ if self.backend in ("trt", "truss") and self.model is None:
66
+ raise ValueError(f"backend='{self.backend}' requires model")
67
+ if self.print_requests and self.backend != "sglang-oai-chat":
68
+ raise ValueError("print_requests is only supported with backend='sglang-oai-chat'")
69
+ if self.tokenize_prompt and self.backend != "sglang":
70
+ raise ValueError("tokenize_prompt is only supported with backend='sglang'")
71
+ return self
72
+
73
+
74
+ def bench_serving_argv(args: BenchServingArgs) -> list[str]:
75
+ """Build the SGLang serving benchmark argv from typed fields."""
76
+ data = args.model_dump(exclude={"timeout_s", "extra_request_body", "header", "extra_args"}, exclude_none=True)
77
+ data["extra_request_body"] = json.dumps(args.extra_request_body, separators=(",", ":")) if args.extra_request_body else None
78
+ data["header"] = [f"{key}={value}" for key, value in (args.header or {}).items()]
79
+ argv = ["python", "-m", "sglang.benchmark.serving"]
80
+ for name, value in data.items():
81
+ if value is False or value is None or value == []:
82
+ continue
83
+ argv.append(f"--{name.replace('_', '-')}")
84
+ if value is True:
85
+ continue
86
+ argv.extend(map(str, value if isinstance(value, list) else (value,)))
87
+ argv.extend(args.extra_args)
88
+ return argv
89
+
90
+
91
+ def parse_bench_serving_metrics(output: str, expected_requests: int | None = None) -> dict[str, float]:
92
+ """Extract Perferox experiment metrics from SGLang benchmark output."""
93
+ metrics = {}
94
+ for raw_line in output.splitlines():
95
+ label, separator, raw_value = raw_line.partition(":")
96
+ if not separator:
97
+ continue
98
+ label = label.strip()
99
+ metric_name = _CONSOLE_METRIC_LABELS.get(label)
100
+ if metric_name is None and (label != "Successful requests" or not expected_requests):
101
+ continue
102
+ raw_value = raw_value.strip()
103
+ if not raw_value:
104
+ continue
105
+ raw_number = raw_value.split(maxsplit=1)[0].rstrip("%")
106
+ try:
107
+ value = float(raw_number)
108
+ except ValueError:
109
+ continue
110
+ if metric_name is None:
111
+ metrics["error_rate"] = max(expected_requests - value, 0.0) / expected_requests
112
+ continue
113
+ metrics[metric_name] = value / 100.0 if metric_name == "cache_hit_rate" else value
114
+ return metrics
@@ -0,0 +1,47 @@
1
+ """Small command-line entry point for Perferox."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from contextlib import closing
7
+ from pathlib import Path
8
+
9
+ from perferox import db
10
+
11
+
12
+ def main(argv: list[str] | None = None) -> int:
13
+ """Open the TUI, launch the main agent, or request a soft stop."""
14
+ parser = argparse.ArgumentParser(prog="perferox")
15
+ parser.add_argument("--cwd", type=Path, default=Path("."), help="repository/root directory")
16
+ parser.add_argument("--db-path", type=Path, default=Path("perferox.sqlite"), help="SQLite state path")
17
+ parser.add_argument("--trace-dir", type=Path, default=Path("traces"), help="trace directory")
18
+ subparsers = parser.add_subparsers(dest="command")
19
+ run_parser = subparsers.add_parser("run", help="start the main graph without opening the TUI")
20
+ run_parser.add_argument("objective", nargs="+", help="objective for the main agent")
21
+ subparsers.add_parser("end", help="request a soft stop without opening the TUI")
22
+ args = parser.parse_args(argv)
23
+
24
+ cwd = args.cwd.resolve()
25
+ db_path = (cwd / args.db_path).resolve()
26
+ trace_dir = (cwd / args.trace_dir).resolve()
27
+
28
+ if args.command is None:
29
+ from perferox.tui import PerferoxTUI
30
+
31
+ PerferoxTUI(cwd=cwd, db_path=db_path, trace_dir=trace_dir).run()
32
+ return 0
33
+ if args.command == "run":
34
+ from perferox.agent_runner import main as run_agent
35
+
36
+ objective = " ".join(args.objective)
37
+ return run_agent(["launch-main", "--db-path", str(db_path), "--trace-dir", str(trace_dir), "--objective", objective, "--cwd", str(cwd)])
38
+ with closing(db.connect(db_path)) as conn:
39
+ db.init_db(conn)
40
+ stopped = db.request_soft_stop(conn)
41
+ db.append_explorer_state(conn, agent_id=None, line="soft stop requested from CLI")
42
+ print(f"soft stop requested for {stopped} running session(s)")
43
+ return 0
44
+
45
+
46
+ if __name__ == "__main__":
47
+ raise SystemExit(main())