@wendellresearch-ai/benchwarmers 0.5.6 → 0.6.0

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.
@@ -60,6 +60,20 @@ ADDING A PROVIDER
60
60
  3. Run `python metrics/connect.py --selftest`: it checks every entry's shape,
61
61
  and every flow against a fake provider.
62
62
  4. Add an endpoint on the Endpoints page, pick the provider and sign in once for real.
63
+
64
+ FOUND ON THIS MACHINE
65
+ ---------------------
66
+ `detect()` looks for model servers running here (LOCAL: Ollama, LM Studio,
67
+ Jan, llama.cpp, LocalAI, vLLM, LiteLLM, CLI Proxy API, OmniRoute), so the
68
+ Endpoints page can offer each one with its address and kind filled in. It
69
+ asks their usual ports, and any port whose listening process is one of them
70
+ by name or command line (`listeners()`), since a port can be changed.
71
+ Loopback only, GET only, no key sent, every port at once. A port alone is not
72
+ proof: each answers /v1/models (or refuses it without a key), and where
73
+ something else could sit on its port, a `sign` from its docs or source says
74
+ which it is (llama.cpp and LocalAI share 8080); without one, the process
75
+ does. Adding one is the ordinary Add form, test before save and all; Ollama
76
+ at its usual port keeps its own flow.
63
77
  """
64
78
 
65
79
  from __future__ import annotations
@@ -68,6 +82,7 @@ import base64
68
82
  import datetime as dt
69
83
  import hashlib
70
84
  import json
85
+ import os
71
86
  import pathlib
72
87
  import re
73
88
  import secrets
@@ -165,6 +180,211 @@ PROVIDERS: dict[str, dict] = {
165
180
  }
166
181
 
167
182
 
183
+ def _rows(data) -> list:
184
+ """An OpenAI /models answer's entries, [] for anything else."""
185
+ rows = data.get("data") if isinstance(data, dict) else None
186
+ return [r for r in rows if isinstance(r, dict)] if isinstance(rows, list) else []
187
+
188
+
189
+ def _version(status: int, data) -> bool:
190
+ """{"version": ...}: Ollama's /api/version, vLLM's and LocalAI's /version."""
191
+ return status == 200 and isinstance(data, dict) and "version" in data
192
+
193
+
194
+ # Model servers detect() looks for: their usual ports; `sign`, a key-free GET
195
+ # and a test of its (status, answer) where the port alone could be another
196
+ # server, none where the port is its own; the endpoint each becomes; where that
197
+ # is said, read 2026-11-08. llama.cpp moved its default from 8080 to 9931
198
+ # (README, --port), so both are asked. Order matters on a shared port: the
199
+ # first whose sign holds. `procs`, how its process's name starts (".exe" and
200
+ # case aside), and `cmd`, a word in its command line for one run by Python or
201
+ # Node: from release binaries, installers and launch code, read 2026-11-09.
202
+ LOCAL: dict[str, dict] = {
203
+ "ollama": {"label": "Ollama", "ports": (11434,), "kind": "local", "provider": "ollama",
204
+ "sign": ("/api/version", _version), "procs": ("ollama",),
205
+ "docs": "https://github.com/ollama/ollama/blob/main/docs/api.md#version"},
206
+ # The app (Scoop's manifest, LM Studio.exe) or its headless daemon (lmstudio.ai/install.sh, llmster).
207
+ "lmstudio": {"label": "LM Studio", "ports": (1234,), "kind": "local", "procs": ("lm studio", "llmster"),
208
+ # Its own REST API: {"models": [{"key", "capabilities", ...}]}, or a refusal when it wants a token.
209
+ "sign": ("/api/v1/models", lambda s, d: s in (401, 403) or (
210
+ s == 200 and isinstance(d, dict) and isinstance(d.get("models"), list))),
211
+ "docs": "https://lmstudio.ai/docs/developer/rest/list"},
212
+ # The server runs in the app itself: Jan, now Jan-Desktop (src-tauri/tauri.conf.json).
213
+ "jan": {"label": "Jan", "ports": (1337,), "kind": "local", "procs": ("jan",),
214
+ "docs": "https://docs.jan.ai/desktop/api-server"},
215
+ "llamacpp": {"label": "llama.cpp server", "ports": (9931, 8080), "kind": "local", "procs": ("llama-server",),
216
+ # Its /v1/models is public even with --api-key, each model owned_by "llamacpp".
217
+ "sign": ("/v1/models", lambda s, d: any(r.get("owned_by") == "llamacpp" for r in _rows(d))),
218
+ "docs": "https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md"},
219
+ "localai": {"label": "LocalAI", "ports": (8080,), "kind": "local",
220
+ # No owned_by in its /v1/models (endpoints/openai/list.go); /version has no middleware.
221
+ "sign": ("/version", _version), "procs": ("local-ai",), # also local-ai-v4.12.1-linux-amd64
222
+ "docs": "https://github.com/mudler/LocalAI/blob/master/core/http/routes/localai.go"},
223
+ "vllm": {"label": "vLLM", "ports": (8000,), "kind": "local", "cmd": ("vllm",),
224
+ "sign": ("/version", _version), # --api-key guards /v1 and a few others, not this
225
+ "docs": "https://docs.vllm.ai/en/latest/cli/serve.html"},
226
+ "litellm": {"label": "LiteLLM", "ports": (4000,), "kind": "hub", "hub": "litellm", "cmd": ("litellm",),
227
+ "sign": ("/health/liveliness", lambda s, d: s == 200 and "alive" in str(d)),
228
+ "docs": "https://docs.litellm.ai/docs/proxy/health"},
229
+ # Its releases, Homebrew's formula, and its Docker image (CLIProxyAPI).
230
+ "cliproxy": {"label": "CLI Proxy API", "ports": (8317,), "kind": "hub", "hub": "cliproxy",
231
+ "sign": ("/", lambda s, d: isinstance(d, dict) and d.get("message") == "CLI Proxy API Server"),
232
+ "procs": ("cli-proxy-api", "cliproxyapi"),
233
+ "docs": "https://github.com/router-for-me/CLIProxyAPI/blob/main/internal/api/server_routes.go"},
234
+ # Node running node_modules/omniroute/dist/server.js, or 9router/app/custom-server.js.
235
+ "omniroute": {"label": "OmniRoute / 9router", "ports": (20128,), "kind": "aggregator",
236
+ "cmd": ("omniroute", "9router"), "docs": "https://github.com/decolua/9router"},
237
+ }
238
+
239
+
240
+ def look(url: str, timeout: float = 0.5, body: dict | None = None, headers: dict | None = None) -> tuple[int, object]:
241
+ """(status, the answer as JSON or text); (0, None) when nothing usable
242
+ answers. A GET, or a POST of `body` as JSON. Never through a proxy or a
243
+ redirect: it reaches `url`'s own host or nothing, a key in `headers` too."""
244
+ import client # its no-redirect handler
245
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), client._NoRedirect)
246
+ sent = {**UA, **({"Content-Type": "application/json"} if body else {}), **(headers or {})}
247
+ try:
248
+ try:
249
+ with opener.open(urllib.request.Request(url, json.dumps(body).encode() if body else None, sent),
250
+ timeout=timeout) as r:
251
+ status, raw = r.status, r.read(1 << 20)
252
+ except urllib.error.HTTPError as e:
253
+ status, raw = e.code, e.read(1 << 16)
254
+ except Exception: # noqa: BLE001 -- refused, timed out, or not HTTP at all
255
+ return 0, None
256
+ try:
257
+ return status, json.loads(raw)
258
+ except ValueError:
259
+ return status, raw.decode("utf-8", "replace")
260
+
261
+
262
+ def listeners() -> list[tuple[int, int, str, str]]:
263
+ """(pid, port, process name, command line) of each TCP listener on loopback
264
+ or all addresses, from the OS's own tools: netstat and PowerShell's process
265
+ list on Windows, ss on Linux, lsof on macOS, ps for a command line there.
266
+ [] when a tool is missing or fails (a container, no rights to see), which
267
+ leaves detect() the usual ports."""
268
+ import subprocess
269
+
270
+ def run(*cmd: str) -> str:
271
+ return subprocess.run(cmd, capture_output=True, text=True, errors="replace", timeout=5,
272
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)).stdout
273
+ try:
274
+ if sys.platform == "win32":
275
+ # A listener's far end is port 0; its state's name is translated, so not asked.
276
+ socks = [(int(p[4]), p[1]) for p in map(str.split, run("netstat", "-ano").splitlines())
277
+ if len(p) == 5 and p[0] == "TCP" and p[2].endswith(":0")]
278
+ # Names and command lines in one look, sooner than tasklist gives names alone.
279
+ info = {p["ProcessId"]: (p["Name"] or "", p["CommandLine"] or "") for p in json.loads(run(
280
+ "powershell", "-NoProfile", "-NonInteractive", "-Command",
281
+ "Get-CimInstance Win32_Process | Select ProcessId,Name,CommandLine | ConvertTo-Json -Compress"))}
282
+ else: # (pid, address, name), then each one's command line
283
+ if sys.platform == "darwin":
284
+ found, pid, name = [], 0, ""
285
+ for line in run("lsof", "-nP", "-iTCP", "-sTCP:LISTEN", "-Fpcn", "+c", "0").splitlines():
286
+ if line[:1] == "p":
287
+ pid = int(line[1:])
288
+ elif line[:1] == "c":
289
+ name = line[1:]
290
+ elif line[:1] == "n":
291
+ found.append((pid, line[1:], name))
292
+ else: # ... users:(("ollama",pid=812,fd=3))
293
+ found = [(int(pid), p[3], name) for p in map(str.split, run("ss", "-ltnp").splitlines())
294
+ if p[:1] == ["LISTEN"] for name, pid in re.findall(r'"([^"]*)",pid=(\d+)', " ".join(p[5:]))]
295
+ pids = ",".join({str(f[0]) for f in found})
296
+ args = dict(line.strip().partition(" ")[::2] for line in run("ps", "-o", "pid=,args=", "-p", pids).splitlines())
297
+ socks, info = [f[:2] for f in found], {f[0]: (f[2], args.get(str(f[0]), "")) for f in found}
298
+ except Exception: # noqa: BLE001 -- missing, refused, timed out, or not what was expected
299
+ return []
300
+ rows = []
301
+ for pid, addr in socks:
302
+ host, _, port = addr.rpartition(":")
303
+ if host.strip("[]") in ("0.0.0.0", "127.0.0.1", "::", "::1", "*") and port.isdigit():
304
+ rows.append((pid, int(port), *info.get(pid, ("", ""))))
305
+ return rows
306
+
307
+
308
+ def detect(skip=(), timeout: float = 0.5) -> list[dict]:
309
+ """The LOCAL servers answering on this machine, but none on a port in
310
+ `skip` (this install's own) or already an endpoint. Each: id, label, port,
311
+ base, kind, hub, provider (Ollama's Connect flow), models (None when the
312
+ list needs a key) and key. Asked: each one's usual ports, and the ports of
313
+ processes that are one of them (`procs`, `cmd`), or Ollama's own
314
+ OLLAMA_HOST; a port no such process holds is asked only if it is usual.
315
+ Every port at once, so a look takes the listing (about a second) and
316
+ `timeout`, a server that hangs included. Loopback only, so a GET here
317
+ never leaves this machine."""
318
+ from concurrent.futures import ThreadPoolExecutor
319
+ taken = set(skip) | _endpoint_ports()
320
+ by_port: dict[int, list[str]] = {}
321
+ host = re.search(r":(\d+)/?$", os.environ.get("OLLAMA_HOST", "").strip()) # as 0.0.0.0:11435
322
+ # ponytail: the listing (about 0.8 s on Windows), then the probes; overlap them if a look must be quicker
323
+ running = listeners() + ([(0, int(host[1]), "ollama", "")] if host else [])
324
+ matched = [(port, sid) for _, port, name, cmd in running for sid, s in LOCAL.items()
325
+ if name.lower().startswith(s.get("procs", ())) or any(c in cmd.lower() for c in s.get("cmd", ()))]
326
+ for port, sid in matched + [(port, sid) for sid, s in LOCAL.items() for port in s["ports"]]:
327
+ if port not in taken and sid not in by_port.setdefault(port, []):
328
+ by_port[port].append(sid)
329
+
330
+ def identify(port: int, ids: list[str]) -> dict | None:
331
+ got = {"/v1/models": look(f"http://127.0.0.1:{port}/v1/models", timeout)}
332
+ status, data = got["/v1/models"]
333
+ keyed = status in (401, 403)
334
+ if not keyed and not (status == 200 and isinstance(data, dict) and isinstance(data.get("data"), list)):
335
+ return None # not an OpenAI-compatible API, or nothing there
336
+ for sid in ids:
337
+ s = LOCAL[sid]
338
+ path, test = s.get("sign") or ("/v1/models", None)
339
+ if path not in got:
340
+ got[path] = look(f"http://127.0.0.1:{port}{path}", timeout)
341
+ if test is None or test(*got[path]):
342
+ own = s.get("provider", "") if port in s["ports"] else "" # Ollama's flow knows only its usual port
343
+ return {"id": sid, "label": s["label"], "port": port, "kind": s["kind"], "hub": s.get("hub", ""),
344
+ "provider": own, "docs": s["docs"],
345
+ "base": PROVIDERS[own]["base"] if own else f"http://127.0.0.1:{port}/v1",
346
+ "models": None if keyed else len(_rows(data)), "key": keyed}
347
+ return None
348
+
349
+ if not by_port:
350
+ return []
351
+ with ThreadPoolExecutor(len(by_port)) as pool:
352
+ return [f for f in pool.map(lambda kv: identify(*kv), by_port.items()) if f]
353
+
354
+
355
+ def _endpoint_ports() -> set[int]:
356
+ """The ports of endpoints on this machine, under any of its names or
357
+ addresses: an endpoint added by a LAN address is the same server. No DNS:
358
+ resolving this machine's own name took seconds on some (macOS CI); an
359
+ address binds only if it is this machine's."""
360
+ import ipaddress
361
+ import socket
362
+ name = socket.gethostname().lower()
363
+
364
+ def here(host: str | None) -> bool:
365
+ if host in ("localhost", "127.0.0.1", "::1", name):
366
+ return True
367
+ try:
368
+ ip = ipaddress.ip_address(host or "")
369
+ except ValueError:
370
+ return False # another name: not resolved here
371
+ try:
372
+ with socket.socket(socket.AF_INET6 if ip.version == 6 else socket.AF_INET) as s:
373
+ s.bind((str(ip), 0))
374
+ return True
375
+ except OSError:
376
+ return False
377
+ ports = set()
378
+ for e in endpoints.load().values():
379
+ url = urllib.parse.urlsplit(str(e.get("base", "")))
380
+ try:
381
+ if here(url.hostname):
382
+ ports.add(url.port or (443 if url.scheme == "https" else 80))
383
+ except ValueError: # a port that is not a number: not one of these
384
+ pass
385
+ return ports
386
+
387
+
168
388
  class ConnectError(Exception):
169
389
  """A sign-in that cannot go on; the text is for the person signing in."""
170
390
 
@@ -515,6 +735,11 @@ def check() -> list[str]:
515
735
  problems.append(f"{pid}: {k} must be an object")
516
736
  if (p.get("entry") or {}).get("kind") not in set(endpoints.KINDS) - {"hub"}:
517
737
  problems.append(f"{pid}: entry.kind is provider, aggregator or local (a hub is added by hand)")
738
+ for sid, s in LOCAL.items(): # the id is the name an endpoint is offered under
739
+ if not endpoints.NAME.match(sid) or not s.get("docs", "").startswith("https://"):
740
+ problems.append(f"local {sid}: the id must match endpoints.NAME, and docs be https")
741
+ if s.get("kind") not in endpoints.KINDS or (s["kind"] == "hub") != (s.get("hub") in endpoints.HUBS):
742
+ problems.append(f"local {sid}: kind is one of endpoints.KINDS, and a hub names one of endpoints.HUBS")
518
743
  return problems
519
744
 
520
745