status-server.py 62 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522
  1. #!/usr/bin/env python3
  2. """Tiny status page and engine MCP tools for a vLLM container.
  3. Runs on its own port from container start, so there is something to look at
  4. while the model loads. It does NOT proxy inference traffic -- putting a Python hop in front of vLLM would add
  5. a failure mode and latency to every request for no benefit.
  6. Stage is read from a file the entrypoint appends to, so the page survives the
  7. entrypoint being mid-exec.
  8. On a multi-node engine every rank polls its PEERS and renders the whole
  9. cluster, so any box answers "is the cluster up?" without checking the others by
  10. hand. A TP rank that never joined is the common failure, and it is only visible
  11. by comparing the nodes. A single-node engine sets no PEERS.
  12. Every setting is an environment variable, and an empty one counts as unset:
  13. compose passes knobs through as `${VAR:-}`, so an empty string means "use the
  14. default", never "the value is empty".
  15. """
  16. import glob
  17. import http.server
  18. import importlib.metadata
  19. import importlib.util
  20. import json
  21. import os
  22. import re
  23. import shutil
  24. import socket
  25. import socketserver
  26. import subprocess
  27. import threading
  28. import time
  29. import urllib.error
  30. import urllib.request
  31. def _env(name: str, default: str = "") -> str:
  32. """The variable's value, or the default when it is unset OR empty."""
  33. v = os.environ.get(name, "").strip()
  34. return v if v else default
  35. SERVICE = _env("SERVICE_NAME", _env("SERVED_NAME", "vllm"))
  36. STAGE_FILE = _env("STAGE_FILE", f"/tmp/{SERVICE}-stage")
  37. PORT = int(_env("STATUS_PORT", "8081"))
  38. # vLLM's own default port. Every recipe sets PORT; a default that named another
  39. # model's port would scrape that engine on a shared box without any error.
  40. VLLM_PORT = int(_env("PORT", "8000"))
  41. START = time.time()
  42. HOSTNAME = socket.gethostname()
  43. ROLE = _env("ROLE", "head")
  44. # Other ranks to poll, "host:port,host:port", for a multi-node engine. Empty
  45. # for a single-node one: this node then renders alone.
  46. PEERS = [p.strip() for p in _env("PEERS").split(",") if p.strip()]
  47. PEER_POLL_S = float(_env("PEER_POLL_S", "4"))
  48. PEER_TIMEOUT_S = float(_env("PEER_TIMEOUT_S", "2"))
  49. # The worker holds a TP rank and never serves an API, so it needs its own
  50. # vocabulary -- otherwise it sits forever on a head-oriented stage and looks
  51. # stuck when it is healthy.
  52. WORKER_STAGES = [
  53. ("starting", "Container started"),
  54. ("joining", "Joining the Ray cluster"),
  55. ("worker-ready", "Holding TP rank -- ready (no API on this node)"),
  56. ]
  57. # One label covers everything between exec and the API binding: weight
  58. # loading, KV cache setup, cudagraph capture and warmup. Weights are a small
  59. # part of that now (~75s of a ~700s boot), so naming only them made the page
  60. # look stuck on a step that had long finished.
  61. _DEFAULT_STAGES = [
  62. ("starting", "Container started"),
  63. ("waiting-workers", "Waiting for TP workers to join"),
  64. ("loading", "Loading model weights and initializing engine"),
  65. ("self-test", "Self-test: checking the model answers correctly"),
  66. ("serving", "Serving"),
  67. ]
  68. def _parse_stages(spec: str) -> list[tuple[str, str]]:
  69. """`key:label` pairs, comma separated, in the order the entrypoint writes
  70. them. A model with a different boot sequence sets STAGES rather than
  71. carrying its own copy of this file. A label cannot contain a comma."""
  72. out = []
  73. for part in spec.split(","):
  74. key, _, label = part.partition(":")
  75. if key.strip():
  76. out.append((key.strip(), label.strip() or key.strip()))
  77. return out
  78. STAGES = _parse_stages(_env("STAGES")) if _env("STAGES") else _DEFAULT_STAGES
  79. # 1 marks the node serving as soon as the engine answers /v1/models. For an
  80. # entrypoint that runs no self-test and execs into vLLM, which leaves nothing
  81. # behind to write "serving". Off by default: a model with a self-test must not
  82. # be called healthy before it passes.
  83. SERVING_WHEN_READY = _env("SERVING_WHEN_READY", "0") == "1"
  84. # Terminal failure states. Kept out of STAGES so they never render as a step to
  85. # be reached -- they replace the current step and turn the page red.
  86. FAILED_STAGES = {"self-test-failed"}
  87. def stages():
  88. return WORKER_STAGES if ROLE == "worker" else STAGES
  89. def read_stage():
  90. try:
  91. with open(STAGE_FILE) as f:
  92. lines = [l.strip() for l in f if l.strip()]
  93. return lines[-1] if lines else "starting", lines
  94. except OSError:
  95. return "starting", []
  96. # Cache directories to report. These are the things that populate slowly during
  97. # a cold start -- FlashInfer JIT is ~170 MB and dominates the wait -- so showing
  98. # their size and growth turns an opaque "loading" into visible progress.
  99. # Resolve FlashInfer's cache the way FlashInfer itself does, rather than from
  100. # FLASHINFER_CACHE_DIR -- that name is a module constant, not an env var, so
  101. # reading it here reported a directory nothing ever wrote to and made a cold
  102. # cache look permanently warm.
  103. _FI_BASE = _env("FLASHINFER_WORKSPACE_BASE", os.path.expanduser("~"))
  104. FLASHINFER_DIR = os.path.join(_FI_BASE, ".cache", "flashinfer")
  105. CACHE_DIRS = [
  106. ("flashinfer JIT", FLASHINFER_DIR),
  107. ("flashinfer autotune", _env("VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR")),
  108. ("torch.compile", _env("VLLM_CACHE_ROOT")),
  109. ("triton", _env("TRITON_CACHE_DIR")),
  110. ("tilelang", _env("TILELANG_CACHE_DIR")),
  111. ]
  112. _size_cache: dict[str, tuple[float, int, float]] = {}
  113. _baseline: dict[str, int] = {}
  114. # A cache directory written to within this many seconds is "active". Size alone
  115. # is a poor progress signal -- a cache can be re-writing entries at the same
  116. # total size, which reads as "idle" when it is the thing holding up the boot.
  117. # The newest mtime says which stage is doing work RIGHT NOW.
  118. ACTIVE_WINDOW_S = float(_env("CACHE_ACTIVE_WINDOW_S", "25"))
  119. CACHE_ROOT_PATH = _env("CACHE_ROOT", "/root/.cache")
  120. def dir_stat(path: str, ttl: float = 4.0) -> tuple[int, float]:
  121. """(bytes, newest mtime). One walk for both; memoised so a 5s refresh is cheap."""
  122. if not path or not os.path.isdir(path):
  123. return -1, 0.0
  124. now = time.time()
  125. hit = _size_cache.get(path)
  126. if hit and now - hit[0] < ttl:
  127. return hit[1], hit[2]
  128. total, newest = 0, 0.0
  129. for root, _, files in os.walk(path):
  130. for f in files:
  131. try:
  132. st = os.stat(os.path.join(root, f))
  133. except OSError:
  134. continue
  135. total += st.st_size
  136. if st.st_mtime > newest:
  137. newest = st.st_mtime
  138. _size_cache[path] = (now, total, newest)
  139. _baseline.setdefault(path, total)
  140. return total, newest
  141. def human(n: int) -> str:
  142. if n < 0:
  143. return "-"
  144. for unit in ("B", "KB", "MB", "GB"):
  145. if n < 1024 or unit == "GB":
  146. return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
  147. n /= 1024.0
  148. return f"{n:.1f} GB"
  149. def cache_rows():
  150. out = []
  151. now = time.time()
  152. for label, path in CACHE_DIRS:
  153. sz, newest = dir_stat(path)
  154. grown = sz - _baseline.get(path, sz) if sz >= 0 else 0
  155. idle = (now - newest) if newest else None
  156. out.append({"name": label, "path": path, "bytes": sz,
  157. "size": human(sz), "grown": human(grown) if grown > 0 else "",
  158. "active": idle is not None and idle < ACTIVE_WINDOW_S,
  159. "idle_s": None if idle is None else int(idle)})
  160. return out
  161. def vllm_up():
  162. try:
  163. with urllib.request.urlopen(
  164. f"http://127.0.0.1:{VLLM_PORT}/v1/models", timeout=2
  165. ) as r:
  166. return r.status == 200
  167. except Exception:
  168. return False
  169. THROUGHPUT_INTERVAL_S = float(_env("THROUGHPUT_INTERVAL_S", "60"))
  170. _tp_lock = threading.Lock()
  171. _tp: list[dict] = [] # newest last, bounded
  172. _TP_KEEP = 15
  173. def scrape_metrics() -> dict | None:
  174. """Counters we need for a rate, or None if the API is not up yet."""
  175. try:
  176. with urllib.request.urlopen(
  177. f"http://127.0.0.1:{VLLM_PORT}/metrics", timeout=5
  178. ) as r:
  179. text = r.read().decode()
  180. except Exception:
  181. return None
  182. out = {"t": time.time()}
  183. wanted = {
  184. "generation_tokens_total": "gen",
  185. "prompt_tokens_total": "prompt",
  186. "num_requests_running": "running",
  187. "num_requests_waiting": "waiting",
  188. "spec_decode_num_draft_tokens_total": "draft",
  189. "spec_decode_num_accepted_tokens_total": "accepted",
  190. "kv_cache_usage_perc": "kv_usage",
  191. "prefix_cache_queries_total": "pc_q",
  192. "prefix_cache_hits_total": "pc_hit",
  193. # Histograms: _sum and _count together give a mean, and differencing
  194. # them between samples gives a mean over the WINDOW rather than over
  195. # the process lifetime -- which is what "is it slow right now" needs.
  196. "time_to_first_token_seconds_sum": "ttft_s",
  197. "time_to_first_token_seconds_count": "ttft_n",
  198. "inter_token_latency_seconds_sum": "itl_s",
  199. "inter_token_latency_seconds_count": "itl_n",
  200. "request_time_per_output_token_seconds_sum": "tpot_s",
  201. "request_time_per_output_token_seconds_count": "tpot_n",
  202. }
  203. finished = {}
  204. waiting_why = {}
  205. for line in text.splitlines():
  206. if not line.startswith("vllm:"):
  207. continue
  208. # request_success_total is labelled by finished_reason (stop, length,
  209. # abort). Aborts are the interesting one -- they are how a failure
  210. # actually shows up here.
  211. # WHY a request is queued decides which knob can help:
  212. # capacity -> blocked on KV cache space; capping prefill chunk size
  213. # does nothing, you need KV or shorter contexts
  214. # deferred -> held back by scheduling; long_prefill_token_threshold
  215. # and the batched-token budget are the levers
  216. # Without this the two are indistinguishable from "waiting: N".
  217. if line.startswith("vllm:num_requests_waiting_by_reason{"):
  218. m = re.search(r'reason="([^"]+)"', line)
  219. if m:
  220. try:
  221. v = float(line.rsplit(" ", 1)[1])
  222. if v > 0:
  223. waiting_why[m.group(1)] = v
  224. except (IndexError, ValueError):
  225. pass
  226. continue
  227. if line.startswith("vllm:request_success_total{"):
  228. m = re.search(r'finished_reason="([^"]+)"', line)
  229. if m:
  230. try:
  231. finished[m.group(1)] = float(line.rsplit(" ", 1)[1])
  232. except (IndexError, ValueError):
  233. pass
  234. continue
  235. for key, short in wanted.items():
  236. if line.startswith(f"vllm:{key}{{"):
  237. try:
  238. out[short] = float(line.rsplit(" ", 1)[1])
  239. except (IndexError, ValueError):
  240. pass
  241. if finished:
  242. out["finished"] = finished
  243. out["waiting_why"] = waiting_why
  244. return out if "gen" in out else None
  245. def throughput_loop():
  246. prev = None
  247. while True:
  248. cur = scrape_metrics()
  249. if cur and prev:
  250. dt = cur["t"] - prev["t"]
  251. if dt > 0:
  252. d_draft = cur.get("draft", 0) - prev.get("draft", 0)
  253. d_acc = cur.get("accepted", 0) - prev.get("accepted", 0)
  254. def win_mean(sum_k, cnt_k):
  255. """Mean over this window from two cumulative histogram fields."""
  256. ds = cur.get(sum_k, 0) - prev.get(sum_k, 0)
  257. dn = cur.get(cnt_k, 0) - prev.get(cnt_k, 0)
  258. return round(ds / dn, 4) if dn > 0 else None
  259. d_q = cur.get("pc_q", 0) - prev.get("pc_q", 0)
  260. d_h = cur.get("pc_hit", 0) - prev.get("pc_hit", 0)
  261. d_kv = (cur.get("kv_usage", 0) or 0) - (prev.get("kv_usage", 0) or 0)
  262. fin_now = cur.get("finished") or {}
  263. fin_prev = prev.get("finished") or {}
  264. d_fin = {k: fin_now[k] - fin_prev.get(k, 0) for k in fin_now
  265. if fin_now[k] - fin_prev.get(k, 0) > 0}
  266. with _tp_lock:
  267. _tp.append({
  268. "t": cur["t"],
  269. "decode": round((cur["gen"] - prev["gen"]) / dt, 1),
  270. "prefill": round((cur.get("prompt", 0) - prev.get("prompt", 0)) / dt, 1),
  271. "running": int(cur.get("running", 0)),
  272. "waiting": int(cur.get("waiting", 0)),
  273. # Only meaningful when drafts were actually produced in
  274. # this window; a lifetime ratio would mask a live drop.
  275. "accept": round(d_acc / d_draft, 3) if d_draft > 0 else None,
  276. "draft_tok_s": round(d_draft / dt, 1) if d_draft > 0 else None,
  277. "ttft_s": win_mean("ttft_s", "ttft_n"),
  278. "inter_token_s": win_mean("itl_s", "itl_n"),
  279. "tpot_s": win_mean("tpot_s", "tpot_n"),
  280. "kv_cache_used_pct": (round(cur["kv_usage"] * 100, 2)
  281. if "kv_usage" in cur else None),
  282. "prefix_hit_rate": round(d_h / d_q, 3) if d_q > 0 else None,
  283. "finished": d_fin or None,
  284. "waiting_why": cur.get("waiting_why") or None,
  285. # Prefill that vLLM has not credited yet. Chunked
  286. # prefill emits no IterationStats until its last chunk,
  287. # so decode and prefill both read 0 through a prompt
  288. # that may take minutes. KV comes from the block
  289. # manager instead, so it keeps moving -- and it is the
  290. # only thing on this page that shows the engine is
  291. # busy. Without this the page says "idle" while a 200K
  292. # prompt is 90 seconds into its prefill.
  293. "uncounted_prefill": bool(
  294. d_kv > 0
  295. and not (cur["gen"] - prev["gen"])
  296. and not (cur.get("prompt", 0) - prev.get("prompt", 0))
  297. ),
  298. })
  299. del _tp[:-_TP_KEEP]
  300. if cur:
  301. prev = cur
  302. time.sleep(THROUGHPUT_INTERVAL_S)
  303. def throughput_rows():
  304. with _tp_lock:
  305. return list(_tp)
  306. # --- host load ------------------------------------------------------------
  307. # Sampled on BOTH roles, unlike throughput. Tonight a boot sat on "loading"
  308. # with every cache flat while three compilers ran at 100% -- the page looked
  309. # idle when the box was saturated. CPU busy needs two /proc/stat samples, so it
  310. # lives on a timer rather than being computed per request.
  311. HOST_SAMPLE_S = float(_env("HOST_SAMPLE_S", "15"))
  312. _host_lock = threading.Lock()
  313. _host: dict = {}
  314. def _cpu_times():
  315. try:
  316. with open("/proc/stat") as f:
  317. parts = f.readline().split()
  318. vals = [float(x) for x in parts[1:8]]
  319. idle = vals[3] + (vals[4] if len(vals) > 4 else 0.0)
  320. return sum(vals), idle
  321. except (OSError, ValueError, IndexError):
  322. return None
  323. def _gpu_sample():
  324. try:
  325. r = subprocess.run(
  326. ["nvidia-smi",
  327. "--query-gpu=utilization.gpu,temperature.gpu,power.draw,clocks.sm,memory.used,memory.total",
  328. "--format=csv,noheader,nounits"],
  329. capture_output=True, text=True, timeout=6)
  330. line = (r.stdout or "").strip().splitlines()
  331. if not line:
  332. return {}
  333. f = [x.strip() for x in line[0].split(",")]
  334. def num(i):
  335. try:
  336. return float(f[i])
  337. except (ValueError, IndexError):
  338. return None
  339. return {"gpu_util": num(0), "gpu_temp": num(1), "gpu_power": num(2),
  340. "gpu_clock": num(3), "gpu_mem_used": num(4), "gpu_mem_total": num(5)}
  341. except Exception:
  342. return {}
  343. def _disk_sectors() -> dict:
  344. """{device: (sectors_read, sectors_written)} for whole devices only.
  345. Partitions are skipped so a device is not double-counted against itself.
  346. """
  347. out = {}
  348. try:
  349. with open("/proc/diskstats") as f:
  350. for line in f:
  351. p = line.split()
  352. if len(p) < 10:
  353. continue
  354. name = p[2]
  355. if not re.fullmatch(r"(nvme\d+n\d+|sd[a-z]+|vd[a-z]+|mmcblk\d+)", name):
  356. continue
  357. try:
  358. out[name] = (int(p[5]), int(p[9]))
  359. except ValueError:
  360. continue
  361. except OSError:
  362. pass
  363. return out
  364. def host_loop():
  365. prev = _cpu_times()
  366. prev_disk = _disk_sectors()
  367. while True:
  368. time.sleep(HOST_SAMPLE_S)
  369. cur = _cpu_times()
  370. busy = None
  371. if prev and cur:
  372. dt, di = cur[0] - prev[0], cur[1] - prev[1]
  373. if dt > 0:
  374. busy = max(0.0, min(100.0, 100.0 * (dt - di) / dt))
  375. prev = cur or prev
  376. sample = {"cpu_busy": None if busy is None else round(busy, 1),
  377. "ncpu": os.cpu_count()}
  378. try:
  379. with open("/proc/uptime") as f:
  380. sample["host_uptime_s"] = int(float(f.read().split()[0]))
  381. except (OSError, ValueError, IndexError):
  382. pass
  383. try:
  384. l1, l5, l15 = os.getloadavg()
  385. sample.update({"load1": round(l1, 2), "load5": round(l5, 2),
  386. "load15": round(l15, 2)})
  387. except OSError:
  388. pass
  389. space = {}
  390. for label, path in (("root", "/"), ("cache", CACHE_ROOT_PATH),
  391. ("logs", MCP_LOG_DIR)):
  392. try:
  393. st = os.statvfs(path)
  394. except OSError:
  395. continue
  396. total = st.f_blocks * st.f_frsize
  397. free = st.f_bavail * st.f_frsize
  398. if total:
  399. space[label] = {
  400. "path": path,
  401. "total_gb": round(total / 2**30, 1),
  402. "free_gb": round(free / 2**30, 1),
  403. "used_pct": round(100 * (1 - free / total), 1),
  404. }
  405. if space:
  406. sample["disk_space"] = space
  407. cur_disk = _disk_sectors()
  408. disks = {}
  409. for dev, (r, w) in cur_disk.items():
  410. pr = prev_disk.get(dev)
  411. if pr:
  412. # 512-byte sectors -> MB/s over the sample interval.
  413. disks[dev] = {
  414. "read_mb_s": round((r - pr[0]) * 512 / 1048576 / HOST_SAMPLE_S, 1),
  415. "write_mb_s": round((w - pr[1]) * 512 / 1048576 / HOST_SAMPLE_S, 1),
  416. }
  417. prev_disk = cur_disk
  418. if disks:
  419. sample["disk"] = disks
  420. sample.update(_gpu_sample())
  421. with _host_lock:
  422. _host.clear()
  423. _host.update(sample)
  424. def host_stats() -> dict:
  425. with _host_lock:
  426. return dict(_host)
  427. def local_status(caches: bool = True):
  428. cur, history = read_stage()
  429. if ROLE != "worker" and cur in ("starting", "loading") and vllm_up():
  430. if SERVING_WHEN_READY:
  431. cur = "serving"
  432. elif "self-test" in dict(STAGES):
  433. # API is bound but the entrypoint has not declared the self-test
  434. # passed. Never claim serving on its behalf.
  435. cur = "self-test"
  436. healthy = (cur == "worker-ready") if ROLE == "worker" else (cur == "serving")
  437. failed = cur in FAILED_STAGES
  438. return {
  439. "stage": cur,
  440. "role": ROLE,
  441. "healthy": healthy,
  442. "failed": failed,
  443. "serving": ROLE != "worker" and cur == "serving",
  444. "elapsed_s": int(time.time() - START),
  445. "history": history,
  446. # Walks every cache directory, so a health probe does not ask for it.
  447. "caches": cache_rows() if caches else [],
  448. "throughput": throughput_rows()[-1] if throughput_rows() else None,
  449. "throughput_history": throughput_rows(),
  450. "load": host_stats(),
  451. "host": HOSTNAME,
  452. }
  453. # --- peer polling -----------------------------------------------------------
  454. # Polled on a timer into a snapshot rather than fetched while rendering, so a
  455. # hung or unplugged peer costs a stale row instead of a page that never loads.
  456. # Peers are asked for /healthz, which is deliberately LOCAL-ONLY -- if it
  457. # included peer data the nodes would poll each other forever. It is /status.json
  458. # without the cache walk.
  459. _peers_lock = threading.Lock()
  460. _peers: dict[str, dict] = {p: {"addr": p, "reachable": None} for p in PEERS}
  461. def poll_peer(addr: str) -> dict:
  462. try:
  463. req = urllib.request.Request(f"http://{addr}/healthz")
  464. with urllib.request.urlopen(req, timeout=PEER_TIMEOUT_S) as r:
  465. data = json.loads(r.read().decode())
  466. # /healthz answers 503 until healthy; that is a valid answer, not
  467. # an error, and urllib only raises on it via HTTPError (handled below).
  468. data.update({"addr": addr, "reachable": True, "seen": time.time()})
  469. return data
  470. except urllib.error.HTTPError as e:
  471. try:
  472. data = json.loads(e.read().decode())
  473. data.update({"addr": addr, "reachable": True, "seen": time.time()})
  474. return data
  475. except Exception:
  476. return {"addr": addr, "reachable": False,
  477. "error": f"HTTP {e.code}", "seen": time.time()}
  478. except Exception as e:
  479. return {"addr": addr, "reachable": False,
  480. "error": type(e).__name__, "seen": time.time()}
  481. def peer_loop():
  482. while True:
  483. for addr in PEERS:
  484. got = poll_peer(addr)
  485. with _peers_lock:
  486. _peers[addr] = got
  487. time.sleep(PEER_POLL_S)
  488. def cluster_rows():
  489. """One row per node, this one included, with self marked."""
  490. with _peers_lock:
  491. snap = dict(_peers)
  492. rows = []
  493. seen_self = False
  494. for addr in PEERS:
  495. p = snap.get(addr, {"addr": addr, "reachable": None})
  496. is_self = p.get("host") == HOSTNAME
  497. seen_self = seen_self or is_self
  498. rows.append({
  499. "addr": addr,
  500. "self": is_self,
  501. # Two different names, for two different jobs:
  502. # addr -- how THIS node polls the peer, often an address on the
  503. # fabric between the ranks. Not resolvable from anywhere
  504. # else, so it must never be a link target.
  505. # name -- how a READER reaches the peer: the box's real hostname,
  506. # which is what resolves on the LAN.
  507. # Falls back to the poll address only when the peer is down and has
  508. # therefore not told us its hostname.
  509. "name": p.get("host") or addr.rsplit(":", 1)[0],
  510. "port": addr.rsplit(":", 1)[-1] if ":" in addr else str(PORT),
  511. "role": p.get("role", "?"),
  512. "stage": p.get("stage", "unreachable" if p.get("reachable") is False else "..."),
  513. "healthy": bool(p.get("healthy")),
  514. "reachable": p.get("reachable"),
  515. "elapsed_s": p.get("elapsed_s"),
  516. "error": p.get("error", ""),
  517. "throughput": p.get("throughput"),
  518. "throughput_history": p.get("throughput_history") or [],
  519. "load": p.get("load") or {},
  520. })
  521. # If this node is not in PEERS (misconfigured, or a name that resolves
  522. # elsewhere) show it anyway rather than rendering a cluster it is absent from.
  523. if not seen_self:
  524. me = local_status()
  525. rows.insert(0, {
  526. "addr": f"{HOSTNAME}:{PORT}", "self": True,
  527. "name": HOSTNAME, "port": str(PORT),
  528. "role": me["role"], "stage": me["stage"], "healthy": me["healthy"],
  529. "reachable": True, "elapsed_s": me["elapsed_s"], "error": "",
  530. "load": host_stats(),
  531. })
  532. return rows
  533. # --- MCP ------------------------------------------------------------------
  534. # OpenWebUI 0.11 speaks MCP natively over streamable HTTP, so the model can ask
  535. # this node about itself instead of a human reading the page. Deliberately
  536. # constrained: every tool is READ-ONLY, takes no arguments, and runs a fixed
  537. # argv (never a shell). There is no auth on this port, so the whitelist IS the
  538. # security boundary -- keep it boring. Notably absent: anything needing the
  539. # Docker socket, which would be root-equivalent on the host.
  540. MCP_PROTOCOL_VERSION = "2025-06-18"
  541. def _run(argv: list[str], timeout: float = 8.0) -> str:
  542. try:
  543. r = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
  544. out = (r.stdout or "") + (r.stderr or "")
  545. return out.strip() or "(no output)"
  546. except FileNotFoundError:
  547. raise RuntimeError(f"{argv[0]} is not available in this container")
  548. except subprocess.TimeoutExpired:
  549. raise RuntimeError(f"{argv[0]} timed out after {timeout}s")
  550. def _read(path: str, default: str = "") -> str:
  551. try:
  552. with open(path) as f:
  553. return f.read().strip()
  554. except OSError:
  555. return default
  556. def _run_or_note(argv: list[str]) -> str:
  557. """_run for a report that should name a missing binary and carry on,
  558. rather than lose every other line to it."""
  559. try:
  560. return _run(argv)
  561. except RuntimeError as e:
  562. return f"({e})"
  563. def _t_node_status():
  564. me = local_status()
  565. return json.dumps({k: me[k] for k in
  566. ("host", "role", "stage", "healthy", "serving", "elapsed_s")},
  567. indent=2)
  568. def _t_cluster_status():
  569. rows = cluster_rows()
  570. return json.dumps({"cluster_healthy": all(r["healthy"] for r in rows),
  571. "nodes": rows}, indent=2, default=str)
  572. def _t_throughput():
  573. hist = throughput_rows()
  574. if not hist:
  575. for n in cluster_rows():
  576. if not n["self"] and n.get("throughput"):
  577. return json.dumps({
  578. "source": n["name"],
  579. "interval_s": THROUGHPUT_INTERVAL_S,
  580. "samples_newest_last": n.get("throughput_history")
  581. or [n["throughput"]],
  582. }, indent=2)
  583. return "No throughput samples yet (the API may still be loading)."
  584. return json.dumps({"source": HOSTNAME, "interval_s": THROUGHPUT_INTERVAL_S,
  585. "samples_newest_last": hist}, indent=2)
  586. _LATENCY_HISTS = (
  587. "time_to_first_token_seconds", "inter_token_latency_seconds",
  588. "request_queue_time_seconds", "request_prefill_time_seconds",
  589. "request_decode_time_seconds",
  590. )
  591. def _hist_buckets() -> dict[str, dict[float, float]]:
  592. """{histogram: {le: cumulative count}} from the engine's metrics endpoint."""
  593. try:
  594. with urllib.request.urlopen(
  595. f"http://127.0.0.1:{VLLM_PORT}/metrics", timeout=5) as r:
  596. body = r.read().decode()
  597. except Exception:
  598. return {}
  599. out: dict[str, dict[float, float]] = {}
  600. for line in body.splitlines():
  601. if not line.startswith("vllm:") or "_bucket" not in line:
  602. continue
  603. name = line.split("{", 1)[0].split("_bucket")[0].split(":", 1)[-1]
  604. if name not in _LATENCY_HISTS:
  605. continue
  606. try:
  607. le = float(line.split('le="', 1)[1].split('"', 1)[0])
  608. out.setdefault(name, {})[le] = float(line.rsplit(" ", 1)[-1])
  609. except (IndexError, ValueError):
  610. pass
  611. return out
  612. def _t_latency_percentiles(window_s: int = 20, **_):
  613. """Percentiles over a window, rather than over the process lifetime."""
  614. window_s = max(5, min(int(window_s or 20), 120))
  615. before = _hist_buckets()
  616. if not before:
  617. return "The engine is not serving metrics yet."
  618. time.sleep(window_s)
  619. after = _hist_buckets()
  620. out: dict[str, object] = {
  621. "window_s": window_s,
  622. "now": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
  623. "note": "Empty means nothing of that kind happened in the window. "
  624. "inter_token_latency needs a request decoding, and one token "
  625. "arrives per scheduler step, so it doubles as step time.",
  626. }
  627. for name in _LATENCY_HISTS:
  628. a, b = before.get(name, {}), after.get(name, {})
  629. if not b:
  630. continue
  631. delta = {le: b[le] - a.get(le, 0.0) for le in b}
  632. les = sorted(delta)
  633. total = delta[les[-1]] if les else 0.0 # +Inf holds the count
  634. if total <= 0:
  635. out[name] = {"observations": 0}
  636. continue
  637. qs = {}
  638. for q in (0.5, 0.9, 0.99):
  639. target = q * total
  640. qs[f"p{int(q * 100)}"] = next(
  641. (le for le in les if delta[le] >= target), les[-1])
  642. out[name] = {"observations": int(total), "seconds_at_most": qs}
  643. return json.dumps(out, indent=2)
  644. def _t_metrics(grep: str = "", **_):
  645. """The engine's Prometheus text, filtered by a regex.
  646. Raw because which counter matters is decided by the question being asked.
  647. The summary tools carry a fixed set and this carries everything else.
  648. """
  649. try:
  650. with urllib.request.urlopen(
  651. f"http://127.0.0.1:{VLLM_PORT}/metrics", timeout=5) as r:
  652. body = r.read().decode()
  653. except Exception as e:
  654. return f"({type(e).__name__}: {e})"
  655. if grep:
  656. try:
  657. rx = re.compile(grep)
  658. except re.error as e:
  659. return f"(bad pattern: {e})"
  660. body = "\n".join(l for l in body.splitlines()
  661. if not l.startswith("#") and rx.search(l))
  662. b = body.encode()
  663. if len(b) > MCP_LOG_MAX_BYTES:
  664. body = (b[:MCP_LOG_MAX_BYTES].decode("utf-8", "ignore")
  665. + f"\n\n[truncated at {MCP_LOG_MAX_BYTES} bytes of {len(b)}]")
  666. return body or "(no lines matched)"
  667. def _t_serve_args(**_):
  668. """The command line the engine actually runs, from /proc/1/cmdline."""
  669. try:
  670. with open("/proc/1/cmdline", "rb") as f:
  671. return f.read().replace(b"\0", b" ").decode(errors="replace")
  672. except OSError as e:
  673. return f"({type(e).__name__}: {e})"
  674. def _t_cache_sizes():
  675. return json.dumps(cache_rows(), indent=2)
  676. # The log mount, reported in the host sample's free-space figures. Reading logs
  677. # belongs to the host agent.
  678. MCP_LOG_DIR = _env("MCP_LOG_DIR", "/logs")
  679. # Cap on one tool answer (the metrics tool). 16 KB, not 64: this lands directly
  680. # in a model's context, and more crowds out the reasoning it is meant to inform.
  681. MCP_LOG_MAX_BYTES = int(_env("MCP_LOG_MAX_BYTES", "16384"))
  682. def _now_header() -> str:
  683. lt = time.strftime("%Y-%m-%d %H:%M:%S %Z", time.localtime())
  684. ut = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
  685. return f"[now: {lt} / {ut} on {HOSTNAME}]"
  686. def _t_versions():
  687. """Driver, CUDA, firmware and Python stack versions."""
  688. out = {}
  689. # /proc/driver/nvidia/version is the kernel module's own claim, which is
  690. # what actually matters after a driver update -- nvidia-smi can report a
  691. # different userspace version if the two drift.
  692. out["nvidia_kernel_module"] = _read("/proc/driver/nvidia/version", "(absent)")
  693. out["nvidia_smi"] = _run_or_note(
  694. ["nvidia-smi",
  695. "--query-gpu=name,driver_version,vbios_version,compute_cap,serial",
  696. "--format=csv"])
  697. nvcc = _run_or_note(["nvcc", "--version"])
  698. out["cuda_toolkit"] = nvcc.splitlines()[-1] if nvcc else ""
  699. for label, path in (("bios_version", "/sys/class/dmi/id/bios_version"),
  700. ("bios_date", "/sys/class/dmi/id/bios_date"),
  701. ("board_name", "/sys/class/dmi/id/board_name"),
  702. ("board_vendor", "/sys/class/dmi/id/board_vendor"),
  703. ("product_name", "/sys/class/dmi/id/product_name")):
  704. v = _read(path)
  705. if v:
  706. out[label] = v
  707. out["kernel"] = _read("/proc/sys/kernel/osrelease")
  708. # Read from package metadata, never by importing: importing torch and vLLM
  709. # here would keep over a gigabyte resident in this process for the rest of
  710. # the container's life. `ray` is mentat's shim, distributed as mentatd.
  711. for label, dists in (("torch", ["torch"]), ("vllm", ["vllm"]),
  712. ("flashinfer", ["flashinfer-python", "flashinfer"]),
  713. ("tilelang", ["tilelang"]), ("triton", ["triton"]),
  714. ("ray", ["mentatd", "ray"])):
  715. for d in dists:
  716. try:
  717. out[f"py_{label}"] = f"{d} {importlib.metadata.version(d)}"
  718. break
  719. except importlib.metadata.PackageNotFoundError:
  720. continue
  721. else:
  722. out[f"py_{label}"] = "(not installed)"
  723. return json.dumps(out, indent=2)
  724. RAY_DASHBOARD = _env("RAY_DASHBOARD", "http://127.0.0.1:8265")
  725. def _t_ray_status():
  726. """Ray cluster resources plus per-actor state.
  727. The dashboard is read over LOCALHOST and never exposed: it carries the Job
  728. Submission API, which runs arbitrary code, so binding it to 0.0.0.0 would
  729. put remote code execution on the LAN. This tool returns a curated read-only
  730. view instead.
  731. """
  732. out = {"resources": _run(["ray", "status"], timeout=15)}
  733. try:
  734. req = urllib.request.Request(f"{RAY_DASHBOARD}/logical/actors")
  735. with urllib.request.urlopen(req, timeout=8) as r:
  736. data = json.loads(r.read().decode())
  737. acts = (data.get("data") or {}).get("actors") or {}
  738. out["actors"] = [
  739. {"class": a.get("actorClass"), "state": a.get("state"),
  740. "ip": (a.get("address") or {}).get("ipAddress"), "pid": a.get("pid")}
  741. for a in acts.values()
  742. ]
  743. # A TP rank whose actor is missing or not ALIVE is the signature of a
  744. # cluster that loads on one node and then waits forever on the other.
  745. alive = sum(1 for a in out["actors"] if a["state"] == "ALIVE")
  746. out["actors_alive"] = f"{alive} of {len(out['actors'])}"
  747. except Exception as e:
  748. out["actors"] = f"dashboard unreachable: {type(e).__name__}: {e}"
  749. return json.dumps(out, indent=2)
  750. # --- filesystem introspection ----------------------------------------------
  751. # Three read-only tools so the model can answer questions about the deployment
  752. # from the files themselves instead of guessing. Tonight's parser-name hunt and
  753. # "which cache was written during the stall" both took a human many round trips
  754. # and are one tool call each.
  755. #
  756. # Every one of these takes ARGUMENTS, so the containment is the security
  757. # boundary, not the tool whitelist. Rules, all enforced below:
  758. # - paths must resolve inside SEARCH_ROOTS (realpath, so symlinks cannot escape)
  759. # - arguments are passed as argv, never through a shell
  760. # - output is capped so one call cannot flood a context window
  761. def _default_search_roots() -> str:
  762. """vLLM's source wherever this image has it, then the caches and logs.
  763. A source build keeps it in /src/vllm; a stock image has only the installed
  764. package. find_spec locates the package without importing it.
  765. """
  766. roots = []
  767. if os.path.isdir("/src/vllm"):
  768. roots.append("/src/vllm")
  769. try:
  770. spec = importlib.util.find_spec("vllm")
  771. if spec and spec.origin:
  772. roots.append(os.path.dirname(spec.origin))
  773. except (ImportError, ValueError):
  774. pass
  775. return ",".join(roots + ["/root/.cache", "/logs", "/cache"])
  776. SEARCH_ROOTS = [r.strip() for r in _env(
  777. "SEARCH_ROOTS", _default_search_roots()).split(",") if r.strip()]
  778. SEARCH_MAX_BYTES = int(_env("SEARCH_MAX_BYTES", "16384"))
  779. def _resolve_under_roots(path: str) -> str:
  780. """Resolve a caller path inside one of SEARCH_ROOTS, or raise."""
  781. if not path:
  782. raise RuntimeError(f"path is required; allowed roots: {', '.join(SEARCH_ROOTS)}")
  783. target = os.path.realpath(path)
  784. for root in SEARCH_ROOTS:
  785. r = os.path.realpath(root)
  786. if target == r or target.startswith(r + os.sep):
  787. if os.path.exists(target):
  788. return target
  789. raise RuntimeError(f"no such path: {path}")
  790. raise RuntimeError(f"path {path!r} is outside the allowed roots: "
  791. f"{', '.join(SEARCH_ROOTS)}")
  792. def _cap(text: str) -> str:
  793. if len(text) > SEARCH_MAX_BYTES:
  794. return (text[:SEARCH_MAX_BYTES]
  795. + f"\n... (truncated at {SEARCH_MAX_BYTES} bytes)")
  796. return text
  797. def _t_find_files(path: str = "", name: str = "", newer_than_minutes: int = 0,
  798. min_size_mb: float = 0, limit: int = 200, **_):
  799. root = _resolve_under_roots(path)
  800. argv = ["find", root]
  801. if name:
  802. if "/" in name or len(name) > 120:
  803. raise RuntimeError("name must be a bare glob, e.g. '*.safetensors'")
  804. argv += ["-name", name]
  805. try:
  806. mins = int(newer_than_minutes)
  807. except (TypeError, ValueError):
  808. mins = 0
  809. if mins > 0:
  810. argv += ["-mmin", f"-{mins}"]
  811. try:
  812. mb = float(min_size_mb)
  813. except (TypeError, ValueError):
  814. mb = 0
  815. if mb > 0:
  816. argv += ["-size", f"+{int(mb * 1024)}k"]
  817. argv += ["-type", "f", "-printf", "%TY-%Tm-%Td %TH:%TM %10s %p\\n"]
  818. out = _run(argv, timeout=30)
  819. lines = out.splitlines()
  820. try:
  821. n = max(1, min(int(limit), 2000))
  822. except (TypeError, ValueError):
  823. n = 200
  824. head = "\n".join(lines[:n])
  825. extra = f"\n... ({len(lines) - n} more matches not shown)" if len(lines) > n else ""
  826. return _now_header() + f" {len(lines)} matches\n" + _cap(head + extra)
  827. def _t_search_files(pattern: str = "", path: str = "", glob: str = "",
  828. limit: int = 100, **_):
  829. root = _resolve_under_roots(path)
  830. if not pattern or len(pattern) > 200:
  831. raise RuntimeError("pattern is required and must be 200 characters or fewer")
  832. try:
  833. n = max(1, min(int(limit), 1000))
  834. except (TypeError, ValueError):
  835. n = 100
  836. argv = ["rg", "--line-number", "--no-heading", "--color", "never",
  837. "--max-count", "20", "--max-filesize", "8M", "-m", str(n)]
  838. if glob:
  839. if len(glob) > 120:
  840. raise RuntimeError("glob must be 120 characters or fewer")
  841. argv += ["--glob", glob]
  842. argv += ["--", pattern, root]
  843. out = _run(argv, timeout=45)
  844. return _now_header() + f" pattern={pattern!r} under {root}\n" + _cap(out)
  845. NO_ARGS = {"type": "object", "properties": {}, "required": []}
  846. MCP_TOOLS = [
  847. ("node_status", "Stage, role, health and uptime of THIS node's model "
  848. "container. The worker node holds a TP rank and never serves an API, so "
  849. "'worker-ready' is its healthy state, not an error.", _t_node_status, NO_ARGS),
  850. ("cluster_status", "Stage and health of every node in the serving cluster, "
  851. "polled from each rank's status server. Use this to answer 'is the "
  852. "cluster up?' -- one node serving while another is not is the common "
  853. "failure. A single-node engine shows only itself.",
  854. _t_cluster_status, NO_ARGS),
  855. ("metrics", "The engine's raw Prometheus text, filtered by a regex. "
  856. "Everything the engine reports is here, including counters the summary "
  857. "tools do not carry.", _t_metrics, {
  858. "type": "object",
  859. "properties": {"grep": {
  860. "type": "string",
  861. "description": "Regex; only matching lines are returned.",
  862. }},
  863. "required": [],
  864. }),
  865. ("serve_args", "The exact command line the engine runs. Says which flags "
  866. "are live rather than what a config file asks for.", _t_serve_args, NO_ARGS),
  867. ("throughput", "Recent decode and prefill tokens/sec, running and queued "
  868. "request counts, and speculative-decoding acceptance rate. Rates are "
  869. "deltas between samples, not lifetime averages. Acceptance is blank "
  870. "without speculative decoding; far below the model's usual rate it "
  871. "suggests mismatched or corrupted weights.", _t_throughput, NO_ARGS),
  872. ("latency_percentiles", "TTFT, inter-token latency, queue time, prefill and "
  873. "decode durations as p50/p90/p99 over a window you choose, taken as the "
  874. "difference between two scrapes so the numbers describe now rather than "
  875. "the process lifetime. Queue time is what request starvation looks like. "
  876. "Inter-token latency is one token per scheduler step, so it also reads as "
  877. "step time. Blocks for the window.", _t_latency_percentiles, {
  878. "type": "object",
  879. "properties": {"window_s": {
  880. "type": "integer",
  881. "description": "Seconds to sample, 5 to 120. Default 20.",
  882. }},
  883. "required": [],
  884. }),
  885. ("cache_sizes", "Sizes of the JIT/compile caches (FlashInfer, torch.compile, "
  886. "Triton, TileLang) and which is being written to right now. Useful for "
  887. "seeing what a slow startup is actually doing.", _t_cache_sizes, NO_ARGS),
  888. # (conditionally appended below when Ray is present)
  889. ]
  890. RAY_TOOL = [
  891. ("ray_status", "Ray cluster resources and the state of every actor, "
  892. "including one RayWorkerProc per tensor-parallel rank. If a rank's actor "
  893. "is missing or not ALIVE, that is why the head loaded and then waited "
  894. "forever. Read from the dashboard over localhost; the dashboard is not "
  895. "exposed because it carries a job API that executes arbitrary code.",
  896. _t_ray_status, NO_ARGS),
  897. ]
  898. if _env("ENABLE_RAY_TOOL", "auto") == "1" or (
  899. _env("ENABLE_RAY_TOOL", "auto") == "auto"
  900. and os.path.isdir("/tmp/ray")
  901. ):
  902. MCP_TOOLS += RAY_TOOL
  903. MCP_TOOLS += [
  904. ("versions", "Firmware and software versions: NVIDIA kernel module, driver, "
  905. "VBIOS, compute capability, CUDA toolkit, system BIOS/board, kernel, and "
  906. "the Python stack (torch, vllm, flashinfer, tilelang, triton, ray). The "
  907. "kernel-module version and nvidia-smi's driver version can disagree after "
  908. "an update, which is worth noticing.", _t_versions, NO_ARGS),
  909. ("find_files", "Find files under an allowed root, filtered by name glob, "
  910. "age and size. Answers questions like 'which cache was written during the "
  911. "stall' or 'how big are the shards' directly, instead of inferring them. "
  912. f"Allowed roots: {', '.join(SEARCH_ROOTS)}.", _t_find_files, {
  913. "type": "object",
  914. "properties": {
  915. "path": {"type": "string",
  916. "description": f"Directory to search. Must be inside "
  917. f"{', '.join(SEARCH_ROOTS)}."},
  918. "name": {"type": "string",
  919. "description": "Bare filename glob, e.g. '*.safetensors'. "
  920. "No slashes."},
  921. "newer_than_minutes": {"type": "integer",
  922. "description": "Only files modified within "
  923. "this many minutes."},
  924. "min_size_mb": {"type": "number",
  925. "description": "Only files at least this large."},
  926. "limit": {"type": "integer", "description": "Max results (default 200)."},
  927. },
  928. "required": ["path"],
  929. }),
  930. ("search_files", "Search file CONTENTS with ripgrep under an allowed root. "
  931. "Use this to read how the running vLLM actually behaves -- which env var a "
  932. "cache honours, what a parser registers as, what a flag defaults to -- "
  933. f"rather than guessing from the name. Allowed roots: {', '.join(SEARCH_ROOTS)}.",
  934. _t_search_files, {
  935. "type": "object",
  936. "properties": {
  937. "pattern": {"type": "string",
  938. "description": "Regular expression. Max 200 characters."},
  939. "path": {"type": "string",
  940. "description": f"Directory to search, inside "
  941. f"{', '.join(SEARCH_ROOTS)}."},
  942. "glob": {"type": "string",
  943. "description": "Restrict to matching files, e.g. '*.py'."},
  944. "limit": {"type": "integer", "description": "Max matches (default 100)."},
  945. },
  946. "required": ["pattern", "path"],
  947. }),
  948. ]
  949. # search_files runs ripgrep, which a stock vLLM image does not ship. Listing it
  950. # anyway would advertise a tool that can only fail.
  951. if shutil.which("rg") is None:
  952. MCP_TOOLS = [t for t in MCP_TOOLS if t[0] != "search_files"]
  953. MCP_BY_NAME = {n: (d, f, sc) for n, d, f, sc in MCP_TOOLS}
  954. # --- per-node dispatch ------------------------------------------------------
  955. # Tools whose answer is a property of ONE machine. Aggregate tools
  956. # (cluster_status, host_load, throughput) already span the cluster and take no
  957. # node argument. Omitting `node` answers locally, which means whichever node the
  958. # MCP client is pointed at -- so the default needs no special case.
  959. NODE_LOCAL = {
  960. "node_status", "cache_sizes", "versions", "find_files", "search_files",
  961. # Only the head runs a Ray dashboard, but keep it node-addressable so a
  962. # client pointed at the worker can still ask the head.
  963. "ray_status",
  964. }
  965. NODE_ARG = {
  966. "node": {
  967. "type": "string",
  968. "description": "Which node to answer for, e.g. a hostname from "
  969. "cluster_status. Omit for the node serving this request.",
  970. }
  971. }
  972. def _peer_addr_for(node: str) -> str | None:
  973. """Match a caller-supplied name against hostname or configured address."""
  974. want = node.strip().lower()
  975. for n in cluster_rows():
  976. if n["self"]:
  977. continue
  978. if want in (str(n.get("host", "")).lower(),
  979. str(n.get("name", "")).lower(),
  980. str(n.get("addr", "")).lower(),
  981. str(n.get("addr", "")).split(":")[0].lower()):
  982. return n["addr"]
  983. return None
  984. def _is_self(node: str) -> bool:
  985. want = node.strip().lower()
  986. return want in (HOSTNAME.lower(), "self", "local", "this")
  987. def proxy_tool_call(node: str, name: str, args: dict) -> dict:
  988. """Forward one tools/call to a peer and return its result verbatim.
  989. The forwarded request carries NO node argument, so the peer answers locally
  990. and cannot bounce it onward -- that is what stops two nodes ping-ponging a
  991. request between them.
  992. """
  993. addr = _peer_addr_for(node)
  994. if addr is None:
  995. known = [HOSTNAME] + [n["host"] or n["name"] for n in cluster_rows()
  996. if not n["self"]]
  997. return {"content": [{"type": "text",
  998. "text": f"unknown node {node!r}; known nodes: "
  999. f"{', '.join(x for x in known if x)}"}],
  1000. "isError": True}
  1001. body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
  1002. "params": {"name": name, "arguments": args}}).encode()
  1003. req = urllib.request.Request(f"http://{addr}/mcp", data=body,
  1004. headers={"Content-Type": "application/json"})
  1005. try:
  1006. with urllib.request.urlopen(req, timeout=30) as r:
  1007. resp = json.loads(r.read().decode())
  1008. except Exception as e:
  1009. return {"content": [{"type": "text",
  1010. "text": f"could not reach {node} at {addr}: "
  1011. f"{type(e).__name__}: {e}"}],
  1012. "isError": True}
  1013. if "result" in resp:
  1014. return resp["result"]
  1015. return {"content": [{"type": "text", "text": json.dumps(resp)}],
  1016. "isError": True}
  1017. def mcp_handle(req: dict) -> dict | None:
  1018. """One JSON-RPC request -> one response, or None for a notification."""
  1019. rid = req.get("id")
  1020. method = req.get("method", "")
  1021. def ok(result):
  1022. return {"jsonrpc": "2.0", "id": rid, "result": result}
  1023. def err(code, msg):
  1024. return {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": msg}}
  1025. if method == "initialize":
  1026. want = (req.get("params") or {}).get("protocolVersion")
  1027. return ok({
  1028. "protocolVersion": want if isinstance(want, str) else MCP_PROTOCOL_VERSION,
  1029. "capabilities": {"tools": {"listChanged": False}},
  1030. "serverInfo": {"name": f"{SERVICE}-status ({HOSTNAME})", "version": "1"},
  1031. })
  1032. if method.startswith("notifications/"):
  1033. return None # nothing to acknowledge
  1034. if method == "ping":
  1035. return ok({})
  1036. if method == "tools/list":
  1037. tools = []
  1038. for n, d, _, sc in MCP_TOOLS:
  1039. if n in NODE_LOCAL:
  1040. sc = dict(sc)
  1041. sc["properties"] = {**sc.get("properties", {}), **NODE_ARG}
  1042. tools.append({"name": n, "description": d, "inputSchema": sc})
  1043. return ok({"tools": tools})
  1044. if method == "tools/call":
  1045. params = req.get("params") or {}
  1046. name = params.get("name")
  1047. entry = MCP_BY_NAME.get(name)
  1048. if entry is None:
  1049. # A tool error is reported in the RESULT, not as a protocol error,
  1050. # so the model can see it and recover rather than the call failing.
  1051. return ok({"content": [{"type": "text",
  1052. "text": f"unknown tool {name!r}; available: "
  1053. f"{', '.join(MCP_BY_NAME)}"}],
  1054. "isError": True})
  1055. args = params.get("arguments") or {}
  1056. if not isinstance(args, dict):
  1057. args = {}
  1058. node = args.pop("node", None)
  1059. if node and name in NODE_LOCAL and not _is_self(str(node)):
  1060. return ok(proxy_tool_call(str(node), name, args))
  1061. # Ignore arguments for tools that declare none, rather than raising a
  1062. # TypeError at a client that sent a harmless empty/extra field.
  1063. if not (entry[2].get("properties") or {}):
  1064. args = {}
  1065. try:
  1066. text = entry[1](**args)
  1067. except Exception as e:
  1068. return ok({"content": [{"type": "text",
  1069. "text": f"{type(e).__name__}: {e}"}],
  1070. "isError": True})
  1071. return ok({"content": [{"type": "text", "text": text}], "isError": False})
  1072. return err(-32601, f"method not found: {method}")
  1073. def memory_report():
  1074. """Host memory beside what the workers say they hold.
  1075. Every allocator counter is per process, so the workers publish theirs and
  1076. this only collects them. `unaccounted` is the interesting column: host
  1077. memory that is neither free nor reserved by torch is NCCL, cuBLAS or the
  1078. JIT caches, and on unified memory it competes with the same pool that
  1079. decides whether this node keeps forking.
  1080. """
  1081. meminfo = {}
  1082. try:
  1083. with open("/proc/meminfo") as fh:
  1084. for line in fh:
  1085. key, _, rest = line.partition(":")
  1086. meminfo[key] = int(rest.split()[0]) * 1024
  1087. except OSError:
  1088. pass
  1089. workers = []
  1090. stats_dir = _env("TORCH_MEM_STATS_DIR")
  1091. if stats_dir:
  1092. for name in sorted(glob.glob(os.path.join(stats_dir, "mem-stats-pid*.json"))):
  1093. try:
  1094. with open(name) as fh:
  1095. workers.append(json.load(fh))
  1096. except (OSError, ValueError):
  1097. continue
  1098. total = meminfo.get("MemTotal", 0)
  1099. reserved = sum(w.get("reserved", 0) for w in workers)
  1100. out = {
  1101. "host": {
  1102. "total": total,
  1103. "free": meminfo.get("MemFree", 0),
  1104. "available": meminfo.get("MemAvailable", 0),
  1105. "cached": meminfo.get("Cached", 0),
  1106. },
  1107. "workers": workers,
  1108. "torch_reserved": reserved,
  1109. "torch_allocated": sum(w.get("allocated", 0) for w in workers),
  1110. }
  1111. if total and workers:
  1112. out["unaccounted"] = total - reserved - meminfo.get("MemAvailable", 0)
  1113. return out
  1114. class Handler(http.server.BaseHTTPRequestHandler):
  1115. # Default is HTTP/1.0, which closes the connection after every response --
  1116. # an MCP client makes a request per tool call and would reconnect each time.
  1117. # Safe here because every response path sets an explicit Content-Length.
  1118. protocol_version = "HTTP/1.1"
  1119. def log_message(self, *a):
  1120. pass
  1121. def _json(self, obj, code):
  1122. body = json.dumps(obj).encode()
  1123. self.send_response(code)
  1124. self.send_header("Content-Type", "application/json")
  1125. self.send_header("Content-Length", str(len(body)))
  1126. self.end_headers()
  1127. self.wfile.write(body)
  1128. def do_POST(self):
  1129. if (self.path.rstrip("/") or "/") != "/mcp":
  1130. self.send_response(404); self.send_header("Content-Length", "0")
  1131. self.end_headers(); return
  1132. try:
  1133. n = int(self.headers.get("Content-Length") or 0)
  1134. payload = json.loads(self.rfile.read(n).decode() or "{}")
  1135. except Exception as e:
  1136. self._json({"jsonrpc": "2.0", "id": None,
  1137. "error": {"code": -32700, "message": f"parse error: {e}"}}, 400)
  1138. return
  1139. # A client may batch requests in a list.
  1140. if isinstance(payload, list):
  1141. out = [r for r in (mcp_handle(m) for m in payload) if r is not None]
  1142. if not out:
  1143. self.send_response(202); self.send_header("Content-Length", "0")
  1144. self.end_headers(); return
  1145. self._json(out, 200)
  1146. return
  1147. resp = mcp_handle(payload)
  1148. if resp is None: # notification: no body
  1149. self.send_response(202); self.send_header("Content-Length", "0")
  1150. self.end_headers(); return
  1151. self._json(resp, 200)
  1152. def do_GET(self):
  1153. path = self.path.rstrip("/") or "/"
  1154. # The transport allows a GET for server-pushed events. Nothing here
  1155. # pushes, so decline rather than hold a connection open forever.
  1156. if path == "/mcp":
  1157. self.send_response(405)
  1158. self.send_header("Allow", "POST")
  1159. self.send_header("Content-Length", "0")
  1160. self.end_headers()
  1161. return
  1162. # LOCAL ONLY -- see the note on _peers above. Peers poll this endpoint.
  1163. if path in ("/healthz", "/status.json"):
  1164. me = local_status(caches=(path == "/status.json"))
  1165. self._json(me, 200 if me["healthy"] else 503)
  1166. return
  1167. if path == "/memory":
  1168. self._json(memory_report(), 200)
  1169. return
  1170. me = local_status()
  1171. if path == "/cluster.json":
  1172. rows = cluster_rows()
  1173. allup = all(r["healthy"] for r in rows) if rows else False
  1174. self._json({"cluster_healthy": allup, "nodes": rows, "local": me},
  1175. 200 if allup else 503)
  1176. return
  1177. cur, healthy, elapsed = me["stage"], me["healthy"], me["elapsed_s"]
  1178. failed = me.get("failed")
  1179. # A failure is not a step in the sequence, so anchor the walk on the last
  1180. # real stage reached; otherwise every step renders as done (ticked) and
  1181. # the page reads as success.
  1182. anchor = cur
  1183. if failed:
  1184. anchor = next((h for h in reversed(me["history"])
  1185. if h in dict(stages())), "loading")
  1186. rows = []
  1187. reached = False
  1188. for key, label in stages():
  1189. if key == anchor:
  1190. mark, cls, reached = ("&#9679;", "bad", True) if failed else ("&#9679;", "now", True)
  1191. elif reached:
  1192. mark, cls = "&#9675;", "todo"
  1193. else:
  1194. mark, cls = "&#10003;", "done"
  1195. rows.append(f'<li class="{cls}"><span>{mark}</span> {label}</li>')
  1196. if failed:
  1197. rows.append('<li class="bad"><span>&#10007;</span> '
  1198. 'SELF-TEST FAILED &mdash; the model loaded but answered '
  1199. 'incorrectly. Check the container log.</li>')
  1200. # The worker scrapes nothing; show whichever node published numbers.
  1201. tp_hist = throughput_rows()
  1202. tp_now = tp_hist[-1] if tp_hist else None
  1203. tp_src = HOSTNAME
  1204. if tp_now is None:
  1205. for n in cluster_rows():
  1206. if not n["self"] and n.get("throughput"):
  1207. tp_now, tp_src = n["throughput"], n["name"]
  1208. break
  1209. def _tp_cells(r):
  1210. acc = "-" if r.get("accept") is None else f'{r["accept"]*100:.1f}%'
  1211. # A zero here is ambiguous, and the ambiguity cost days: vLLM does
  1212. # not credit prompt tokens until a chunked prefill emits its first
  1213. # token, so a 200K prompt reads 0/0 for minutes while the GPU is
  1214. # saturated. Say which zero this is.
  1215. pre = (f'{r["prefill"]:.0f}' if not r.get("uncounted_prefill")
  1216. else '<span class=now>prefilling</span>')
  1217. return (f'<td class=n>{r["decode"]:.1f}</td>'
  1218. f'<td class=n>{pre}</td>'
  1219. f'<td class=n>{r["running"]}</td>'
  1220. f'<td class=n>{r["waiting"]}</td>'
  1221. f'<td class=n>{acc}</td>')
  1222. if tp_now:
  1223. hist = "".join(
  1224. f'<tr><td>{time.strftime("%H:%M:%S", time.localtime(r["t"]))}</td>'
  1225. f'{_tp_cells(r)}</tr>' for r in reversed(tp_hist[-8:]))
  1226. if not hist:
  1227. hist = f'<tr><td>now</td>{_tp_cells(tp_now)}</tr>'
  1228. tp_html = (
  1229. f'<h2>throughput &middot; {tp_src}</h2><table>'
  1230. f'<tr><th>time</th><th>decode tok/s</th><th>prefill tok/s</th>'
  1231. f'<th>running</th><th>queued</th><th>spec accept</th></tr>'
  1232. f'{hist}</table>'
  1233. f'<div class=foot style="margin-top:.5rem">'
  1234. f'sampled every {int(THROUGHPUT_INTERVAL_S)}s &middot; '
  1235. f'rates are deltas between samples, not lifetime averages'
  1236. f'<br>&ldquo;prefilling&rdquo; means KV is growing but vLLM has '
  1237. f'not credited the tokens yet &mdash; a long prompt reports '
  1238. f'nothing until its last chunk</div>')
  1239. else:
  1240. tp_html = ""
  1241. cache_html = []
  1242. for c in cache_rows():
  1243. if c["active"]:
  1244. cls, when = "now", "writing now"
  1245. elif c["idle_s"] is None:
  1246. cls, when = "", "-"
  1247. else:
  1248. cls, when = "", f'{c["idle_s"]}s ago'
  1249. dot = " &#9679;" if c["active"] else ""
  1250. cache_html.append(
  1251. f'<tr><td class="{cls}">{c["name"]}{dot}</td>'
  1252. f'<td class=n>{c["size"]}</td>'
  1253. f'<td class="n g">{c["grown"]}</td>'
  1254. f'<td class="n {cls}">{when}</td></tr>')
  1255. crows = []
  1256. for n in cluster_rows():
  1257. if n["reachable"] is False:
  1258. dot, cls, detail = "&#9679;", "bad", "unreachable"
  1259. elif n["healthy"]:
  1260. dot, cls, detail = "&#9679;", "ok", n["stage"]
  1261. else:
  1262. dot, cls, detail = "&#9679;", "wait", n["stage"]
  1263. el = "" if n["elapsed_s"] is None else f"{n['elapsed_s']//60}m{n['elapsed_s']%60:02d}s"
  1264. # Link the reachable hostname, NOT n["addr"] -- see cluster_rows.
  1265. link = (n["name"] if n["self"]
  1266. else f'<a href="http://{n["name"]}:{n["port"]}/">{n["name"]}</a>')
  1267. tag = ' <span class="me">this node</span>' if n["self"] else ""
  1268. ld = n.get("load") or {}
  1269. def _pct(v):
  1270. return "-" if v is None else f"{float(v):.0f}%"
  1271. cpu = _pct(ld.get("cpu_busy"))
  1272. gpu = _pct(ld.get("gpu_util"))
  1273. if ld.get("gpu_temp") is not None:
  1274. gpu += f" {ld['gpu_temp']:.0f}&deg;C"
  1275. dsk = ld.get("disk") or {}
  1276. if dsk:
  1277. rd = sum(d["read_mb_s"] for d in dsk.values())
  1278. wr = sum(d["write_mb_s"] for d in dsk.values())
  1279. io = f"{rd:.0f}/{wr:.0f}"
  1280. else:
  1281. io = "-"
  1282. up = ld.get("host_uptime_s")
  1283. if up is None:
  1284. upt = "-"
  1285. elif up >= 86400:
  1286. upt = f"{up // 86400}d{(up % 86400) // 3600}h"
  1287. elif up >= 3600:
  1288. upt = f"{up // 3600}h{(up % 3600) // 60}m"
  1289. else:
  1290. upt = f"{up // 60}m"
  1291. crows.append(
  1292. f'<tr><td class="{cls}">{dot}</td><td>{link}{tag}</td>'
  1293. f'<td>{n["role"]}</td><td class="{cls}">{detail}</td>'
  1294. f'<td class=n>{el}</td><td class=n>{upt}</td>'
  1295. f'<td class=n>{cpu}</td><td class=n>{gpu}</td><td class=n>{io}</td></tr>')
  1296. body = f"""<!doctype html><meta charset=utf-8>
  1297. <meta http-equiv=refresh content=5>
  1298. <title>{SERVICE} &middot; {cur}</title>
  1299. <style>
  1300. body{{font:14px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;
  1301. background:#111;color:#ddd;margin:0;padding:2.5rem;}}
  1302. h1{{font-size:1.1rem;margin:0 0 .25rem;color:#fff}}
  1303. h2{{font-size:.8rem;margin:1.75rem 0 .5rem;color:#888;font-weight:400;
  1304. text-transform:uppercase;letter-spacing:.08em}}
  1305. .sub{{color:#888;margin-bottom:1.5rem}}
  1306. ul{{list-style:none;padding:0;max-width:46rem}}
  1307. li{{padding:.35rem 0}} li span{{display:inline-block;width:1.5rem}}
  1308. .done{{color:#4a8}} .now{{color:#fc6;font-weight:600}} .todo{{color:#555}}
  1309. table{{border-collapse:collapse;font-size:13px}}
  1310. th{{text-align:left;color:#888;font-weight:400;padding:.2rem 1.5rem .2rem 0}}
  1311. td{{padding:.2rem 1.5rem .2rem 0;color:#bbb}}
  1312. td.n{{text-align:right;font-variant-numeric:tabular-nums}}
  1313. td.g{{color:#4a8}}
  1314. td.ok{{color:#4a8}} td.wait{{color:#fc6}} td.bad{{color:#e66}}
  1315. .me{{color:#666;font-size:11px}}
  1316. .foot{{margin-top:1.75rem;color:#666;font-size:12px}}
  1317. a{{color:#6af}}
  1318. </style>
  1319. <h1>{SERVICE} <span style="color:#888;font-weight:400">&middot; {ROLE}</span></h1>
  1320. <div class=sub>{HOSTNAME} &middot; {elapsed//60}m{elapsed%60:02d}s elapsed</div>
  1321. <ul>{''.join(rows)}</ul>
  1322. <h2>cluster</h2>
  1323. <table>
  1324. <tr><th></th><th>node</th><th>role</th><th>stage</th><th>container</th><th>host up</th><th>cpu</th><th>gpu</th><th>disk r/w</th></tr>
  1325. {''.join(crows)}
  1326. </table>
  1327. {tp_html}
  1328. <h2>caches</h2>
  1329. <table>
  1330. <tr><th>cache</th><th>size</th><th>grown</th><th>last write</th></tr>
  1331. {''.join(cache_html)}
  1332. </table>
  1333. <div class=foot>
  1334. {("Holding a TP rank; the API lives on the head node." if ROLE == "worker"
  1335. else "API ready at <a href='/v1/models'>:%d</a>" % VLLM_PORT) if healthy
  1336. else "Starting up; each stage above ticks as it completes."}
  1337. <br>refreshes every 5s &middot; <a href="/status.json">status.json</a>
  1338. &middot; <a href="/cluster.json">cluster.json</a>
  1339. &middot; MCP at <code>/mcp</code> ({len(MCP_TOOLS)} read-only tools)
  1340. </div>"""
  1341. b = body.encode()
  1342. self.send_response(200)
  1343. self.send_header("Content-Type", "text/html; charset=utf-8")
  1344. self.send_header("Content-Length", str(len(b)))
  1345. self.end_headers()
  1346. self.wfile.write(b)
  1347. class Server(socketserver.ThreadingTCPServer):
  1348. allow_reuse_address = True
  1349. daemon_threads = True
  1350. if __name__ == "__main__":
  1351. threading.Thread(target=peer_loop, daemon=True).start()
  1352. threading.Thread(target=host_loop, daemon=True).start()
  1353. if ROLE != "worker":
  1354. # Only the head serves an API, so only it has metrics to scrape.
  1355. threading.Thread(target=throughput_loop, daemon=True).start()
  1356. Server(("0.0.0.0", PORT), Handler).serve_forever()