@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.
- package/.github/logo-dark.svg +49 -0
- package/.github/logo.svg +49 -0
- package/AGENTS.md +4 -1
- package/BENCHMARKS.md +160 -0
- package/GATEWAY.md +59 -12
- package/PRIVACY.md +25 -6
- package/README.md +62 -15
- package/SECURITY.md +20 -9
- package/USAGE.md +87 -14
- package/VERSION +1 -1
- package/cli.py +12 -1
- package/gateway.py +122 -91
- package/mcp_server.py +2 -1
- package/metrics/alerts.py +25 -0
- package/metrics/bench_collections.py +58 -9
- package/metrics/catalog.py +54 -9
- package/metrics/client.py +51 -1
- package/metrics/connect.py +225 -0
- package/metrics/decide.py +690 -27
- package/metrics/discover.py +191 -11
- package/metrics/endpoints.py +4 -6
- package/metrics/guard.py +6 -4
- package/metrics/ledger.py +3 -1
- package/metrics/locked.py +102 -1
- package/metrics/offload.py +36 -19
- package/metrics/owui.py +26 -0
- package/metrics/route.py +19 -4
- package/metrics/scheduler.py +11 -1
- package/metrics/sources.py +15 -2
- package/metrics/test_decide.py +275 -7
- package/metrics/wrong.py +21 -9
- package/package.json +3 -1
- package/scripts/auto_retire.py +96 -21
- package/scripts/onboard.py +44 -3
- package/scripts/test_connect.py +152 -3
- package/scripts/test_first_run.py +4 -2
- package/scripts/test_formats.py +100 -1
- package/scripts/test_serve_edit.py +327 -4
- package/serve.py +1746 -170
- package/suite/README.md +5 -2
- package/suite/run.py +226 -6
- package/suite/test_pick.py +15 -0
- package/suite/test_score.py +82 -1
- package/ui/bench.css +44 -0
- package/ui/bench.js +940 -207
package/metrics/catalog.py
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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')
|
package/metrics/connect.py
CHANGED
|
@@ -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
|
|