benchscope 1.0.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.
Files changed (43) hide show
  1. benchscope/__init__.py +3 -0
  2. benchscope/__main__.py +4 -0
  3. benchscope/benches/__init__.py +1 -0
  4. benchscope/benches/base.py +54 -0
  5. benchscope/benches/runner.py +185 -0
  6. benchscope/benches/sglang_bench.py +77 -0
  7. benchscope/benches/vllm_bench.py +94 -0
  8. benchscope/cli.py +46 -0
  9. benchscope/config.py +91 -0
  10. benchscope/constants.py +49 -0
  11. benchscope/datasets.py +200 -0
  12. benchscope/gpu.py +39 -0
  13. benchscope/parser.py +93 -0
  14. benchscope/server/__init__.py +3 -0
  15. benchscope/server/api_config.py +97 -0
  16. benchscope/server/api_logs.py +356 -0
  17. benchscope/server/api_test.py +64 -0
  18. benchscope/server/app.py +99 -0
  19. benchscope/server/state.py +18 -0
  20. benchscope/server/status.py +97 -0
  21. benchscope/server/test_manager.py +367 -0
  22. benchscope/server/ws.py +56 -0
  23. benchscope/summary.py +148 -0
  24. benchscope/webui/assets/LogView-BDFIduo7.css +1 -0
  25. benchscope/webui/assets/LogView-BMsVPLVq.js +1 -0
  26. benchscope/webui/assets/MetricsCharts-D1wU0LbK.css +1 -0
  27. benchscope/webui/assets/MetricsCharts-DHO93JrC.js +1 -0
  28. benchscope/webui/assets/SettingsView-7uuTFqXU.css +1 -0
  29. benchscope/webui/assets/SettingsView-DJmFSQbU.js +1 -0
  30. benchscope/webui/assets/TestView-81VlxBZU.js +4 -0
  31. benchscope/webui/assets/TestView-BAwcOtR2.css +1 -0
  32. benchscope/webui/assets/antd-DWALckI0.js +478 -0
  33. benchscope/webui/assets/echarts-Bb6yjXMn.js +60 -0
  34. benchscope/webui/assets/index-Dfr0hp72.js +2 -0
  35. benchscope/webui/assets/index-EhoNt9Gv.css +1 -0
  36. benchscope/webui/assets/vue-Ch4zjUb1.js +37 -0
  37. benchscope/webui/index.html +19 -0
  38. benchscope-1.0.0.dist-info/METADATA +136 -0
  39. benchscope-1.0.0.dist-info/RECORD +43 -0
  40. benchscope-1.0.0.dist-info/WHEEL +5 -0
  41. benchscope-1.0.0.dist-info/entry_points.txt +2 -0
  42. benchscope-1.0.0.dist-info/licenses/LICENSE +176 -0
  43. benchscope-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,356 @@
1
+ """日志管理 API:运行目录列表、预览、下载、数据集上传、汇总解析、分析数据。"""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ import re
7
+ import threading
8
+ from pathlib import Path
9
+
10
+ from fastapi import APIRouter, File, HTTPException, UploadFile
11
+ from fastapi.responses import FileResponse, PlainTextResponse
12
+
13
+ from benchscope.server.state import state
14
+
15
+ log = logging.getLogger("benchscope.api_logs")
16
+
17
+ router = APIRouter(prefix="/api/logs", tags=["logs"])
18
+
19
+ TEXT_SUFFIXES = {".log", ".csv", ".txt", ".json", ".md", ".sh", ".py"}
20
+
21
+
22
+ # ----------------------------------------------------------------------
23
+ # 运行列表
24
+ @router.get("/runs")
25
+ def list_runs():
26
+ logs_dir = state.config.logs_dir
27
+ runs = []
28
+ if logs_dir.exists():
29
+ for d in sorted(logs_dir.iterdir(), key=lambda p: p.name, reverse=True):
30
+ if not d.is_dir():
31
+ continue
32
+ files = sorted(
33
+ (p.name, p.stat().st_size) for p in d.iterdir() if p.is_file()
34
+ )
35
+ run_info = _load_run_json(d)
36
+ runs.append({
37
+ "run_id": d.name,
38
+ "dir": str(d),
39
+ "files": [{"name": n, "size": s} for n, s in files],
40
+ "meta": {
41
+ "framework": (run_info or {}).get("framework_name"),
42
+ "model": (run_info or {}).get("model"),
43
+ "status": (run_info or {}).get("status"),
44
+ "started_at": (run_info or {}).get("started_at"),
45
+ "finished_at": (run_info or {}).get("finished_at"),
46
+ } if run_info else {},
47
+ })
48
+ return {"runs": runs}
49
+
50
+
51
+ def _load_run_json(d: Path) -> dict | None:
52
+ p = d / "run.json"
53
+ if p.exists():
54
+ try:
55
+ return json.loads(p.read_text(encoding="utf-8"))
56
+ except Exception:
57
+ return None
58
+ return None
59
+
60
+
61
+ @router.get("/runs/{run_id}")
62
+ def get_run(run_id: str):
63
+ d = _resolve_run_dir(run_id)
64
+ run_info = _load_run_json(d) or {}
65
+ files = sorted((p.name, p.stat().st_size) for p in d.iterdir() if p.is_file())
66
+ return {"run_id": run_id, "dir": str(d), "files": files, "run": run_info}
67
+
68
+
69
+ # ----------------------------------------------------------------------
70
+ # 预览 / 下载
71
+ @router.get("/runs/{run_id}/preview")
72
+ def preview_file(run_id: str, name: str, tail: int = 500):
73
+ d = _resolve_run_dir(run_id)
74
+ p = _resolve_file(d, name)
75
+ suffix = p.suffix.lower()
76
+ if suffix not in TEXT_SUFFIXES and name not in ("run.json",):
77
+ raise HTTPException(status_code=400, detail="该文件不是文本文件,请下载查看")
78
+ try:
79
+ content = p.read_text(encoding="utf-8", errors="replace")
80
+ except Exception as e:
81
+ raise HTTPException(status_code=500, detail=f"读取失败: {e}")
82
+ lines = content.splitlines()
83
+ if len(lines) > tail:
84
+ preview = "\n".join(lines[-tail:])
85
+ truncated = len(lines) - tail
86
+ else:
87
+ preview = content
88
+ truncated = 0
89
+ return {"name": name, "truncated": truncated, "total_lines": len(lines), "content": preview}
90
+
91
+
92
+ @router.get("/runs/{run_id}/download")
93
+ def download_file(run_id: str, name: str):
94
+ d = _resolve_run_dir(run_id)
95
+ p = _resolve_file(d, name)
96
+ return FileResponse(p, filename=name)
97
+
98
+
99
+ # ----------------------------------------------------------------------
100
+ # 自定义数据集上传
101
+ @router.get("/datasets")
102
+ def list_datasets():
103
+ ds_dir = state.config.datasets_dir / "uploads"
104
+ files = []
105
+ if ds_dir.exists():
106
+ for p in sorted(ds_dir.iterdir(), key=lambda x: x.name):
107
+ if p.is_file():
108
+ files.append({"name": p.name, "size": p.stat().st_size, "path": str(p)})
109
+ return {"datasets": files, "dir": str(ds_dir)}
110
+
111
+
112
+ @router.post("/datasets/upload")
113
+ async def upload_dataset(file: UploadFile = File(...)):
114
+ ds_dir = state.config.datasets_dir / "uploads"
115
+ ds_dir.mkdir(parents=True, exist_ok=True)
116
+ dest = ds_dir / (file.filename or "dataset.jsonl")
117
+ dest = _unique_path(dest)
118
+ content = await file.read()
119
+ dest.write_bytes(content)
120
+ return {"ok": True, "name": dest.name, "path": str(dest), "size": len(content)}
121
+
122
+
123
+ @router.delete("/datasets/{name}")
124
+ def delete_dataset(name: str):
125
+ p = state.config.datasets_dir / "uploads" / name
126
+ if p.exists() and p.is_file():
127
+ p.unlink()
128
+ return {"ok": True}
129
+ raise HTTPException(status_code=404, detail="文件不存在")
130
+
131
+
132
+ # ----------------------------------------------------------------------
133
+ # 汇总 / 分析数据
134
+ @router.get("/runs/{run_id}/summary")
135
+ def run_summary(run_id: str, threshold: float | None = None):
136
+ """返回该次运行的 mean / P99 两套记录与分析数据。
137
+
138
+ 优先读 run.json(含完整指标),否则回退解析汇总 CSV。
139
+ """
140
+ d = _resolve_run_dir(run_id)
141
+ run_info = _load_run_json(d) or {}
142
+ threshold = threshold if threshold is not None else run_info.get("tpot_threshold_ms")
143
+
144
+ records = _records_from_run(run_info, d)
145
+ if not records:
146
+ return {
147
+ "run_id": run_id, "records_mean": [], "records_p99": [],
148
+ "best_mean": {}, "best_p99": {}, "threshold": threshold, "meta": run_info,
149
+ }
150
+
151
+ mean_rows = _to_display_rows(records, "mean")
152
+ p99_rows = _to_display_rows(records, "p99")
153
+ return {
154
+ "run_id": run_id,
155
+ "records_mean": mean_rows,
156
+ "records_p99": p99_rows,
157
+ "records": _to_merged_rows(records),
158
+ "best_mean": _find_best(mean_rows, threshold),
159
+ "best_p99": _find_best(p99_rows, threshold),
160
+ "threshold": threshold,
161
+ "meta": run_info,
162
+ }
163
+
164
+
165
+ def _to_merged_rows(records: list[dict]) -> list[dict]:
166
+ """合并 mean / P99 的展示行(日志项面板用)。"""
167
+ out = []
168
+ for r in records:
169
+ m = r.get("metrics", {})
170
+ out.append({
171
+ "label": r.get("label") or r.get("case"),
172
+ "input_len": r.get("input_len"),
173
+ "output_len": r.get("output_len"),
174
+ "concurrency": r.get("concurrency"),
175
+ "output_mean": m.get("output_mean", m.get("output")),
176
+ "peakoutput_mean": m.get("peakoutput_mean", m.get("peakoutput")),
177
+ "total_mean": m.get("total_mean", m.get("total")),
178
+ "ttft_mean": m.get("ttft_mean", m.get("ttft")),
179
+ "tpot_mean": m.get("tpot_mean", m.get("tpot")),
180
+ "itl_mean": m.get("itl_mean", m.get("itl")),
181
+ "ttft_p99": m.get("ttft_p99", m.get("ttft")),
182
+ "tpot_p99": m.get("tpot_p99", m.get("tpot")),
183
+ "itl_p99": m.get("itl_p99", m.get("itl")),
184
+ "single_user": m.get("single_user"),
185
+ })
186
+ return out
187
+
188
+
189
+ def _records_from_run(run_info: dict, d: Path) -> list[dict]:
190
+ rows = run_info.get("rows") or []
191
+ if rows:
192
+ return [r for r in rows if isinstance(r, dict) and "metrics" in r]
193
+ # 回退:解析 CSV
194
+ csvs = sorted(d.glob("*_p99.log")) or sorted(d.glob("*.log"))
195
+ for csv in csvs:
196
+ parsed = parse_summary_csv(csv)
197
+ if parsed:
198
+ return parsed
199
+ return []
200
+
201
+
202
+ def _to_display_rows(records: list[dict], key: str) -> list[dict]:
203
+ out = []
204
+ for r in records:
205
+ m = r.get("metrics", {})
206
+ out.append({
207
+ "label": r.get("label") or r.get("case"),
208
+ "input_len": r.get("input_len"),
209
+ "output_len": r.get("output_len"),
210
+ "concurrency": r.get("concurrency"),
211
+ # 吞吐指标无 p99 变体,两种块都取 mean
212
+ "output": m.get(f"output_{key}", m.get("output_mean", m.get("output"))),
213
+ "peakoutput": m.get(f"peakoutput_{key}", m.get("peakoutput_mean", m.get("peakoutput"))),
214
+ "total": m.get(f"total_{key}", m.get("total_mean", m.get("total"))),
215
+ "ttft": m.get(f"ttft_{key}", m.get("ttft")),
216
+ "itl": m.get(f"itl_{key}", m.get("itl")),
217
+ "tpot": m.get(f"tpot_{key}", m.get("tpot")),
218
+ "single_user": m.get("single_user"),
219
+ })
220
+ return out
221
+
222
+
223
+ def _find_best(rows: list[dict], threshold) -> dict:
224
+ if not rows:
225
+ return {}
226
+ try:
227
+ threshold = float(threshold)
228
+ except (TypeError, ValueError):
229
+ threshold = None
230
+ if threshold is None:
231
+ return {}
232
+ by_case: dict = {}
233
+ for r in rows:
234
+ by_case.setdefault(r.get("label"), []).append(r)
235
+ best = {}
236
+ for label, items in by_case.items():
237
+ valid = [(float(r["tpot"]), r) for r in items if r.get("tpot") is not None]
238
+ if not valid:
239
+ continue
240
+ below = [(t, r) for t, r in valid if t < threshold]
241
+ if below:
242
+ t, r = max(below, key=lambda x: x[0])
243
+ else:
244
+ t, r = min(valid, key=lambda x: x[0])
245
+ best[label] = {"concurrency": r["concurrency"], "tpot": t, "row": r}
246
+ return best
247
+
248
+
249
+ # ----------------------------------------------------------------------
250
+ def parse_summary_csv(path: Path) -> list[dict]:
251
+ """解析汇总 CSV(含用例块头)为记录列表。"""
252
+ records = []
253
+ try:
254
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
255
+ except Exception:
256
+ return records
257
+ cur = None
258
+ for ln in lines:
259
+ m = re.match(r"测试条件:(\S+)\s*\|\s*输入=(\d+)?\s*\|\s*输出=(\d+)", ln)
260
+ if m:
261
+ cur = {"label": m.group(1), "input_len": int(m.group(2)) if m.group(2) else None,
262
+ "output_len": int(m.group(3)) if m.group(3) else None}
263
+ continue
264
+ if ln.startswith("并发数,") or ln.startswith("=") or not ln.strip():
265
+ continue
266
+ parts = [x.strip() for x in ln.split(",")]
267
+ if len(parts) >= 7 and cur:
268
+ try:
269
+ conc = int(parts[0])
270
+ except ValueError:
271
+ continue
272
+ values = []
273
+ for v in parts[1:7]:
274
+ try:
275
+ values.append(float(v))
276
+ except ValueError:
277
+ values.append(None)
278
+ records.append({
279
+ "label": cur["label"], "input_len": cur["input_len"], "output_len": cur["output_len"],
280
+ "concurrency": conc,
281
+ "metrics": {
282
+ "output": values[0], "peakoutput": values[1], "total": values[2],
283
+ "ttft": values[3], "tpot": values[4], "itl": values[5],
284
+ },
285
+ })
286
+ return records
287
+
288
+
289
+ # ----------------------------------------------------------------------
290
+ # ShareGPT 数据集下载(modelscope)
291
+ _SHAREGPT_STATE = {"state": "idle", "path": None, "error": None, "thread": None}
292
+
293
+
294
+ def _sharegpt_cached_path() -> str | None:
295
+ from benchscope.datasets import SHAREGPT_JSONL_NAME
296
+
297
+ p = state.config.datasets_dir / "sharegpt" / SHAREGPT_JSONL_NAME
298
+ if p.exists() and p.stat().st_size > 0:
299
+ return str(p)
300
+ return None
301
+
302
+
303
+ @router.get("/datasets/sharegpt")
304
+ def sharegpt_status():
305
+ cached = _sharegpt_cached_path()
306
+ if cached and _SHAREGPT_STATE["state"] == "idle":
307
+ _SHAREGPT_STATE.update(state="done", path=cached, error=None)
308
+ return dict(_SHAREGPT_STATE)
309
+
310
+
311
+ @router.post("/datasets/sharegpt/download")
312
+ def sharegpt_download():
313
+ if _SHAREGPT_STATE["thread"] and _SHAREGPT_STATE["thread"].is_alive():
314
+ return dict(_SHAREGPT_STATE)
315
+ from benchscope.datasets import ensure_sharegpt
316
+
317
+ def _work():
318
+ try:
319
+ _SHAREGPT_STATE.update(state="downloading", error=None)
320
+ path = ensure_sharegpt(state.config.datasets_dir, force=True)
321
+ _SHAREGPT_STATE.update(state="done", path=str(path), error=None)
322
+ except Exception as e:
323
+ log.exception("sharegpt 下载失败")
324
+ _SHAREGPT_STATE.update(state="error", error=str(e)[:300])
325
+
326
+ _SHAREGPT_STATE["thread"] = threading.Thread(target=_work, daemon=True)
327
+ _SHAREGPT_STATE["thread"].start()
328
+ return dict(_SHAREGPT_STATE)
329
+
330
+
331
+ # ----------------------------------------------------------------------
332
+ def _resolve_run_dir(run_id: str) -> Path:
333
+ logs_dir = state.config.logs_dir
334
+ d = logs_dir / run_id
335
+ if not d.is_dir():
336
+ raise HTTPException(status_code=404, detail=f"运行记录不存在: {run_id}")
337
+ return d
338
+
339
+
340
+ def _resolve_file(d: Path, name: str) -> Path:
341
+ p = (d / name).resolve()
342
+ if not p.is_file() or not str(p).startswith(str(d.resolve())):
343
+ raise HTTPException(status_code=404, detail="文件不存在")
344
+ return p
345
+
346
+
347
+ def _unique_path(p: Path) -> Path:
348
+ if not p.exists():
349
+ return p
350
+ stem, suffix = p.stem, p.suffix
351
+ i = 1
352
+ while True:
353
+ cand = p.with_name(f"{stem}_{i}{suffix}")
354
+ if not cand.exists():
355
+ return cand
356
+ i += 1
@@ -0,0 +1,64 @@
1
+ """测试启停与进度 API。"""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+
6
+ from fastapi import APIRouter, HTTPException
7
+ from pydantic import BaseModel
8
+
9
+ from benchscope.server.state import state
10
+
11
+ log = logging.getLogger("benchscope.api_test")
12
+
13
+ router = APIRouter(prefix="/api/test", tags=["test"])
14
+
15
+
16
+ class StartRequest(BaseModel):
17
+ framework: str = "vllm"
18
+ model: str = ""
19
+ tokenizer: str = ""
20
+ dataset: dict = {}
21
+ concurrency_list: list = []
22
+ gpu: dict = {}
23
+ request_rate: str | float = "inf"
24
+ tpot_threshold_ms: float | None = None
25
+ precision: str = ""
26
+ curated: dict = {}
27
+ extra_args: list = []
28
+ force: bool = False
29
+
30
+
31
+ @router.post("/start")
32
+ def start_test(req: StartRequest):
33
+ try:
34
+ run = state.tests.start(req.model_dump())
35
+ return {"ok": True, "run_id": run.run_id, "run": run.snapshot()}
36
+ except RuntimeError as e:
37
+ raise HTTPException(status_code=409, detail=str(e))
38
+
39
+
40
+ @router.post("/preview")
41
+ def preview_test(req: StartRequest):
42
+ """预览将要执行的命令(不实际运行)。"""
43
+ from benchscope.server.test_manager import build_command_lines
44
+
45
+ try:
46
+ lines = build_command_lines(req.model_dump(), state.config)
47
+ return {"ok": True, "commands": lines, "count": len(lines)}
48
+ except Exception as e:
49
+ raise HTTPException(status_code=400, detail=str(e))
50
+
51
+
52
+ @router.post("/stop")
53
+ def stop_test():
54
+ state.tests.stop()
55
+ return {"ok": True}
56
+
57
+
58
+ @router.get("/status")
59
+ def test_status():
60
+ run = state.tests.current
61
+ return {
62
+ "running": state.tests.running,
63
+ "run": run.snapshot() if run else None,
64
+ }
@@ -0,0 +1,99 @@
1
+ """FastAPI 应用装配:API 路由 + WebSocket + 前端静态托管。"""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from contextlib import asynccontextmanager
6
+ from pathlib import Path
7
+
8
+ from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.responses import FileResponse
11
+ from fastapi.staticfiles import StaticFiles
12
+
13
+ from benchscope.server import api_config, api_logs, api_test
14
+ from benchscope.server.state import state
15
+
16
+ log = logging.getLogger("benchscope.app")
17
+
18
+ WEBUI_DIR = Path(__file__).resolve().parent.parent / "webui"
19
+
20
+
21
+ @asynccontextmanager
22
+ async def lifespan(app: FastAPI):
23
+ state.monitor.start()
24
+ yield
25
+ state.monitor.stop()
26
+ state.tests.stop()
27
+
28
+
29
+ def create_app() -> FastAPI:
30
+ app = FastAPI(
31
+ title="benchscope",
32
+ description="vLLM / SGLang 推理服务性能测试工具",
33
+ version="0.1.0",
34
+ lifespan=lifespan,
35
+ )
36
+
37
+ app.add_middleware(
38
+ CORSMiddleware,
39
+ allow_origins=["*"],
40
+ allow_credentials=True,
41
+ allow_methods=["*"],
42
+ allow_headers=["*"],
43
+ )
44
+
45
+ app.include_router(api_config.router)
46
+ app.include_router(api_test.router)
47
+ app.include_router(api_logs.router)
48
+
49
+ # ---------------- WebSocket ----------------
50
+ @app.websocket("/ws")
51
+ async def websocket_endpoint(ws: WebSocket):
52
+ await ws.accept()
53
+ import asyncio
54
+ import json
55
+
56
+ loop = asyncio.get_running_loop()
57
+ state.hub.register(ws, loop)
58
+ try:
59
+ # 连接后立即推送一次状态
60
+ snap = state.monitor.check_once(broadcast=False)
61
+ snap["web"] = "ready"
62
+ await ws.send_text(json.dumps({"type": "status", "status": snap}, ensure_ascii=False))
63
+ run = state.tests.current
64
+ if run:
65
+ await ws.send_text(
66
+ json.dumps({"type": "run_snapshot", "run": run.snapshot()}, ensure_ascii=False)
67
+ )
68
+ while True:
69
+ await ws.receive_text() # 保持连接,忽略客户端消息
70
+ except WebSocketDisconnect:
71
+ pass
72
+ except Exception:
73
+ pass
74
+ finally:
75
+ state.hub.unregister(ws)
76
+
77
+ # ---------------- 前端静态托管 ----------------
78
+ assets_dir = WEBUI_DIR / "assets"
79
+ if assets_dir.is_dir():
80
+ app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
81
+
82
+ @app.get("/", include_in_schema=False)
83
+ def index():
84
+ return _spa_response()
85
+
86
+ @app.get("/{full_path:path}", include_in_schema=False)
87
+ def spa_fallback(full_path: str):
88
+ if full_path.startswith("api/") or full_path == "ws":
89
+ raise HTTPException(status_code=404, detail="Not Found")
90
+ return _spa_response()
91
+
92
+ return app
93
+
94
+
95
+ def _spa_response():
96
+ index = WEBUI_DIR / "index.html"
97
+ if index.exists():
98
+ return FileResponse(index)
99
+ raise HTTPException(status_code=404, detail="前端未构建,请先运行 `npm run build` 或使用 Vite dev server")
@@ -0,0 +1,18 @@
1
+ """服务端全局状态单例。"""
2
+ from __future__ import annotations
3
+
4
+ from benchscope.config import ConfigManager
5
+ from benchscope.server.status import StatusMonitor
6
+ from benchscope.server.test_manager import TestManager
7
+ from benchscope.server.ws import WebSocketHub
8
+
9
+
10
+ class AppState:
11
+ def __init__(self):
12
+ self.config = ConfigManager()
13
+ self.hub = WebSocketHub()
14
+ self.monitor = StatusMonitor(self.config, self.hub)
15
+ self.tests = TestManager(self.config, self.hub)
16
+
17
+
18
+ state = AppState()
@@ -0,0 +1,97 @@
1
+ """推理服务状态监控:心跳探测 /v1/models,广播状态变化。"""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import threading
6
+ import time
7
+ from typing import Optional
8
+
9
+ import requests
10
+
11
+ from benchscope.constants import STATUS_OFFLINE, STATUS_READY
12
+
13
+ log = logging.getLogger("benchscope.status")
14
+
15
+
16
+ class StatusMonitor:
17
+ INTERVAL = 5.0
18
+
19
+ def __init__(self, config, hub):
20
+ self.config = config
21
+ self.hub = hub
22
+ self._stop = threading.Event()
23
+ self._thread: Optional[threading.Thread] = None
24
+ self._lock = threading.Lock()
25
+ self.status = {
26
+ "web": STATUS_READY,
27
+ "inference": STATUS_OFFLINE,
28
+ "last_check": None,
29
+ "models": [],
30
+ "error": None,
31
+ }
32
+
33
+ # ------------------------------------------------------------------
34
+ def start(self) -> None:
35
+ if self._thread and self._thread.is_alive():
36
+ return
37
+ self._stop.clear()
38
+ self._thread = threading.Thread(target=self._loop, name="status-monitor", daemon=True)
39
+ self._thread.start()
40
+
41
+ def stop(self) -> None:
42
+ self._stop.set()
43
+ if self._thread:
44
+ self._thread.join(timeout=3)
45
+
46
+ # ------------------------------------------------------------------
47
+ def _loop(self) -> None:
48
+ while not self._stop.is_set():
49
+ try:
50
+ self.check_once(broadcast=True)
51
+ except Exception:
52
+ log.exception("状态探测异常")
53
+ self._stop.wait(self.INTERVAL)
54
+
55
+ def check_once(self, broadcast: bool = False) -> dict:
56
+ api = self.config.api
57
+ base = (api.get("base_url") or "").rstrip("/")
58
+ headers = {}
59
+ if api.get("api_key"):
60
+ headers["Authorization"] = f"Bearer {api['api_key']}"
61
+ headers.update(api.get("extra_headers") or {})
62
+
63
+ models: list[str] = []
64
+ error = None
65
+ inference = STATUS_READY
66
+ if not base:
67
+ inference, error = STATUS_OFFLINE, "未配置 API 地址"
68
+ else:
69
+ try:
70
+ resp = requests.get(f"{base}/v1/models", headers=headers, timeout=4)
71
+ resp.raise_for_status()
72
+ data = resp.json()
73
+ for item in data.get("data", []):
74
+ mid = item.get("id")
75
+ if mid and mid not in models:
76
+ models.append(mid)
77
+ except Exception as e:
78
+ inference = STATUS_OFFLINE
79
+ error = str(e)[:300]
80
+
81
+ changed = False
82
+ with self._lock:
83
+ old = self.status["inference"]
84
+ self.status.update(
85
+ inference=inference, last_check=time.strftime("%H:%M:%S"),
86
+ models=models, error=error,
87
+ )
88
+ changed = old != inference or bool(self.status["models"]) != bool(models)
89
+ snapshot = dict(self.status)
90
+
91
+ if broadcast and changed:
92
+ self.hub.broadcast({"type": "status", "status": snapshot})
93
+ return snapshot
94
+
95
+ def snapshot(self) -> dict:
96
+ with self._lock:
97
+ return dict(self.status)