@wendellresearch-ai/benchwarmers 0.5.5 → 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.
@@ -31,6 +31,12 @@ WHERE THE FACTS COME FROM
31
31
  the same weights, but a provider can cap context lower.
32
32
  - Open WebUI's /api/models, where a connection passes metadata through
33
33
  (Inception, Reflection do; most do not).
34
+ - A local model server's own API, for what its /v1/models leaves out: LM
35
+ Studio's /api/v1/models (vision, tool use, reasoning; the context window a
36
+ loaded model runs with, else the most it can take) and Ollama's /api/show
37
+ (its capabilities, and `<arch>.context_length` from model_info, the trained
38
+ window: Ollama may run it with less). Like a connection's own listing, these
39
+ beat a same-id match from anywhere else.
34
40
  - Everything else, including rate limits, by hand: freellmapi.co/models is a
35
41
  good place to look them up. The bench never fetches it; its data licence is
36
42
  not stated.
@@ -64,8 +70,9 @@ LIMIT_LABELS = {"rpm": "requests per minute", "rpd": "requests per day", "tpm":
64
70
 
65
71
 
66
72
  def _read(path: pathlib.Path) -> dict:
73
+ import locked
67
74
  try:
68
- data = json.loads(path.read_text(encoding="utf-8"))
75
+ data = json.loads(locked.read_text(path)) # inside a render, from its snapshot
69
76
  except (OSError, ValueError):
70
77
  return {}
71
78
  return data if isinstance(data, dict) else {}
@@ -151,6 +158,39 @@ def _fetch(url: str, headers: dict | None = None) -> dict:
151
158
  return json.load(r)
152
159
 
153
160
 
161
+ def _native(name: str, served) -> tuple[str, dict]:
162
+ """What a local model server says of its own models that its /v1/models
163
+ leaves out: LM Studio's /api/v1/models, else Ollama's /api/show, one call
164
+ for each of `served`. (source, {served id: facts}); ("", {}) from any other."""
165
+ import connect
166
+ import endpoints
167
+ root, secret = endpoints.base(name).removesuffix("/v1"), endpoints.key(name)
168
+ auth = endpoints.auth_headers("chat", secret) if secret else {}
169
+ status, data = connect.look(root + "/api/v1/models", 5, headers=auth)
170
+ out: dict[str, dict] = {}
171
+ if status == 200 and isinstance(data, dict) and isinstance(data.get("models"), list):
172
+ for m in data["models"]:
173
+ caps, runs = m.get("capabilities") or {}, m.get("loaded_instances") or []
174
+ # Loaded: the window it runs with now; else the most it can take.
175
+ ctx = min(filter(None, ((i.get("config") or {}).get("context_length") for i in runs)),
176
+ default=m.get("max_context_length"))
177
+ facts = {"context_window": ctx, "vision": caps.get("vision"),
178
+ "tools": caps.get("trained_for_tool_use"), "reasoning": "reasoning" in caps}
179
+ # A request names the key, a variant (key@q4_k_m) or a loaded instance's id.
180
+ for i in (m.get("key"), *(m.get("variants") or ()), *(r.get("id") for r in runs)):
181
+ out[i] = facts
182
+ return "lmstudio /api/v1/models", out
183
+ for s in served:
184
+ status, d = connect.look(root + "/api/show", 5, {"model": s}, auth)
185
+ if status == 200 and isinstance(d, dict) and isinstance(d.get("capabilities"), list):
186
+ caps, mi = d["capabilities"], d.get("model_info") or {}
187
+ # ponytail: the trained window; Ollama runs a model with its own num_ctx
188
+ # (OLLAMA_CONTEXT_LENGTH, a Modelfile's) and cuts a longer prompt silently.
189
+ out[s] = {"context_window": mi.get(f"{mi.get('general.architecture')}.context_length"),
190
+ "vision": "vision" in caps, "tools": "tools" in caps, "reasoning": "thinking" in caps}
191
+ return ("ollama /api/show" if out else ""), out
192
+
193
+
154
194
  def _watch_price(alias: str, served: str, conn: str, old: dict | None, new: dict | None) -> None:
155
195
  """A free model that now has a price: raise it, before calls start failing.
156
196
  models.dev can lag, so this is an early warning, not proof; the bench's own
@@ -173,14 +213,11 @@ def _watch_price(alias: str, served: str, conn: str, old: dict | None, new: dict
173
213
 
174
214
  def refresh() -> dict:
175
215
  """Rebuild config/model-catalog.json for every registered chat model."""
216
+ import discover
176
217
  import endpoints
177
218
  import sources
178
219
  from run import _registered_names
179
- for name in sources.FEEDS:
180
- try:
181
- sources.fetch(name) # at most once a day; cached copy otherwise
182
- except Exception: # noqa: BLE001
183
- pass
220
+ discover.refresh() # the catalogs and providers' lists, at most once a day
184
221
  before = catalog()
185
222
  registered = _registered_names() # alias -> served name
186
223
  try:
@@ -191,11 +228,17 @@ def refresh() -> dict:
191
228
  # is registered on: the direct openrouter endpoint lists qwen/qwen3.8-27b
192
229
  # too, and pooling the listings filed OpenRouter's 1M window as groq's.
193
230
  listings: dict[str, dict] = {}
231
+ native: dict[str, tuple[str, dict]] = {} # a local server's own API, what /v1/models leaves out
194
232
  for name in endpoints.load():
195
233
  try:
196
234
  listings[name] = endpoints.models(name)
197
235
  except Exception: # noqa: BLE001
198
236
  listings[name] = {}
237
+ try:
238
+ if endpoints.kind(name) == "local":
239
+ native[name] = _native(name, listings[name])
240
+ except Exception: # noqa: BLE001 -- an answer of another shape: nothing from it
241
+ pass
199
242
  home = {r["alias"]: r["endpoint"] for r in endpoints.registry()}
200
243
  models = {}
201
244
  for alias, served in registered.items():
@@ -272,9 +315,11 @@ def refresh() -> dict:
272
315
  structured_output=bool(feats & {"json_mode", "structured_outputs"}))
273
316
  if entry.get("input_modalities") is not None:
274
317
  own["vision"] = "image" in entry["input_modalities"]
275
- for k, v in own.items():
276
- if v is not None:
277
- rec[k], src[k] = v, f"{ep or 'endpoint'} listing"
318
+ said, facts = native.get(ep, ("", {}))
319
+ for mine, by in ((own, f"{ep or 'endpoint'} listing"), (facts.get(served, {}), said)):
320
+ for k, v in mine.items():
321
+ if v is not None:
322
+ rec[k], src[k] = v, by
278
323
  rec["sources"] = src
279
324
  models[alias] = rec
280
325
  _watch_price(alias, served, conn, before.get(alias, {}).get("cost"), rec.get("cost"))
package/metrics/client.py CHANGED
@@ -57,7 +57,7 @@ down sets.
57
57
  WHAT EVERY CALL LEAVES BEHIND (2b)
58
58
  ----------------------------------
59
59
  - a ledger line (metrics/ledger.py): tokens in and out, seconds, status,
60
- attempts, caller;
60
+ attempts, caller, and a failed call's error (`clip`: its start and end);
61
61
  - quota records from the reply's rate-limit headers and any 429 body
62
62
  (metrics/quota.py);
63
63
  - a silent-swap note when the reply names another model than the one
@@ -119,6 +119,7 @@ import json
119
119
  import os
120
120
  import pathlib
121
121
  import random
122
+ import re
122
123
  import sys
123
124
  import threading
124
125
  import time
@@ -852,6 +853,11 @@ def _record(alias: str, res: dict, ctx: dict, t0: float, purpose: str) -> None:
852
853
  row["swap"] = True
853
854
  if res.get("budget_retry"):
854
855
  row["budget_retry"] = res["budget_retry"]
856
+ if status == "error":
857
+ # What failed, so the uptime report can tell our quota from the
858
+ # provider being down for calls no other log keeps (the suite's).
859
+ # Redacted first: an upstream may echo the request, images and all.
860
+ row["error"] = clip(redact(res["error"] or ""))
855
861
  ledger.append(row)
856
862
  except Exception as e: # noqa: BLE001
857
863
  # The slot stays taken until it expires (the call's deadline and a
@@ -906,6 +912,45 @@ def output(res: dict, timeout: float) -> str:
906
912
  return res["error"] or (res["text"] + "\n" if res["text"] else "")
907
913
 
908
914
 
915
+ DATA_URLS = re.compile(r"data:[\w/.+-]*;base64,[A-Za-z0-9+/=_\\-]*", re.I)
916
+ URLS = re.compile(r"https?://[^\s\"'<>\\]+", re.I)
917
+ BASE64 = re.compile(r"[A-Za-z0-9+/]{100,}={0,2}")
918
+
919
+
920
+ def placeholder(url: str) -> str:
921
+ """How an image is written down (the call log, offloads, the dashboard,
922
+ error text, the merger's copy of the request), never its bytes: a data
923
+ URL by its size, an http URL without its query (a signed URL's signature
924
+ is a credential) or a user:password."""
925
+ if url[:5].lower() == "data:":
926
+ return f"[image: data URL, {(len(url) * 3 // 4 + 999) // 1000:,} KB]"
927
+ u = urllib.parse.urlsplit(url)
928
+ return f"[image: {u.scheme}://{u.netloc.rpartition('@')[2]}{u.path[:200]}{'?...' if u.query else ''}]"
929
+
930
+
931
+ def redact(text: str) -> str:
932
+ """`text` with each inline image as its placeholder, every http(s) URL
933
+ without its query or user:password, and any other long base64 run as its
934
+ size: an upstream may echo the request in an error, in whatever form, and
935
+ errors are logged, shown and learned from."""
936
+ def bare(m):
937
+ try:
938
+ u = urllib.parse.urlsplit(m.group())
939
+ except ValueError: # an unparseable netloc, such as "[::1"
940
+ return "[url]"
941
+ return f"{u.scheme}://{u.netloc.rpartition('@')[2]}{u.path}{'?...' if u.query else ''}"
942
+ text = URLS.sub(bare, DATA_URLS.sub(lambda m: placeholder(m.group()), text))
943
+ return BASE64.sub(lambda m: f"[base64, {(len(m.group()) * 3 // 4 + 999) // 1000:,} KB]", text)
944
+
945
+
946
+ def clip(text: str, head: int = 200, tail: int = 300) -> str:
947
+ """An error kept short for a log, its start AND its end: the start says the
948
+ HTTP status (`Error: Error code: 429 - ...`), the end what a long body
949
+ (headers, a traceback, an HTML page) finally said. The uptime report
950
+ (suite/run.uptime_class) classifies from it."""
951
+ return text if len(text) <= head + tail else f"{text[:head]} … {text[-tail:]}"
952
+
953
+
909
954
  LOG_LOCKED = "sqlite3.OperationalError: database is locked"
910
955
 
911
956
 
@@ -930,6 +975,11 @@ def _selftest() -> None:
930
975
  assert not llm_log_failed_only("", tb) # locked before the call
931
976
  assert not llm_log_failed_only("half an ans", "openai.APIError: stream cut\n")
932
977
  assert not llm_log_failed_only("pong", tb + "Error: something after\n")
978
+ # A long error keeps its status (the start) and its last words (the end), and no image.
979
+ long = "Error: Error code: 429 - " + "x" * 900 + " retry later"
980
+ assert clip(long).startswith("Error: Error code: 429") and clip(long).endswith("retry later")
981
+ assert len(clip(long)) == 503 and clip("short") == "short"
982
+ assert "AAAA" not in clip(redact("Error code: 400 - data:image/png;base64," + "A" * 900))
933
983
  ok = (b'event: x\ndata: {"type":"response.output_text.delta","delta":"re"}\n\n'
934
984
  b'data: {"type":"response.completed","response":{"model":"m","usage":{"total_tokens":3},'
935
985
  b'"output":[{"type":"reasoning"},{"type":"message","content":[{"type":"output_text","text":"ready"}]}]}}\n')
@@ -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