Skip to content

Commit e5ca975

Browse files
committed
Make the model scanner work on machines that are not the author's
An audit across realistic model sets found the discovery layer filled every role on the author's box and left roles empty nearly everywhere else. Three causes. Undeclared size was treated as disqualifying. /v1/models states no parameter count, so every OpenAI-compatible and cloud model arrived with param_b=None against a rule reading `pb is not None and pb >= 18` — GPT-4o was Execution-only while a local 8B held the same role. The asymmetry was accidental: three lines above, unknown tool_calling is already ELIGIBLE-but-unproven, and _score gives an unknown size no credit, so a measured model still outranks a maybe. A test pins that ordering. No threshold was loosened. A measured 8B still cannot be Council, because it genuinely is not council-class and saying otherwise would be the flattering answer. role_basis now records whether a role was held on measured grounds or merely undeclared ones — a guess and a measurement should not be presented as the same claim. The role fallback was capability-blind: asked for Vision with no vision model installed, it returned a code model, because the chain fell back to Execution regardless of what was asked. That is not degradation — a text model handed an image answers wrongly or errors. Vision and Embedding now refuse rather than substitute, so a caller can say "no vision model installed" instead of relaying confident nonsense. Quality-tier substitutions are still permitted and now carry `substituted`, `requested_role`, and a reason; previously the only signal was comparing the returned role against one the caller had to remember asking for. A refusal was filed as an absence. The probes sent no Authorization header, so a runtime behind `vllm serve --api-key` answered 401 and was reported offline — sending the operator to restart a server that was running and had simply refused an unauthenticated request. The key now travels with the probe, an HTTP error proves reachability, and 401/403 reports authorized:false with the remedy. 15 new tests, including a property test that renaming a model to "frobozz-42x" cannot change its classification. 687 tests pass.
1 parent 14c03e0 commit e5ca975

9 files changed

Lines changed: 314 additions & 42 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ badge is red, the claim that this works is not currently true.
251251

252252
```bash
253253
cd backend
254-
python -m pytest -q # 672 tests
254+
python -m pytest -q # 687 tests
255255
```
256256

257257
---

backend/main.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7263,9 +7263,13 @@ def _runtime_extra_probes():
72637263
out = []
72647264
try:
72657265
conn = sqlite3.connect(DB_PATH); c = conn.cursor()
7266-
for cid, name, base, at in c.execute(
7267-
"SELECT id,name,base_url,api_type FROM connections"):
7268-
out.append({"runtime": name or cid, "url": base, "api_type": at or "ollama"})
7266+
# The api_key travels with the probe: a runtime behind `--api-key` used to
7267+
# answer 401 and be filed as offline, which sent the operator to restart a
7268+
# server that was running and had merely refused an unauthenticated request.
7269+
for cid, name, base, at, key in c.execute(
7270+
"SELECT id,name,base_url,api_type,api_key FROM connections"):
7271+
out.append({"runtime": name or cid, "url": base,
7272+
"api_type": at or "ollama", "api_key": key or None})
72697273
conn.close()
72707274
except Exception:
72717275
pass

backend/runtime_registry.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@
2828
_COUNCIL_MIN_B = 30.0 # council = the heaviest models
2929

3030

31+
# Roles a text model physically CANNOT stand in for. Substituting here does not
32+
# degrade quality — it produces confident nonsense, so the router must refuse.
33+
HARD_CAPABILITY_ROLES = ("Vision", "Embedding")
34+
35+
3136
def classify(model: Dict[str, Any]) -> List[str]:
3237
"""Capability → roles. A model may hold several roles. No model names used."""
3338
roles: List[str] = []
@@ -48,12 +53,21 @@ def classify(model: Dict[str, Any]) -> List[str]:
4853
if tools is not False and small:
4954
roles.append("Execution")
5055

51-
# Reasoning: explicit thinking capability OR a mid/large model.
52-
if thinking or (pb is not None and pb >= _REASON_MIN_B):
56+
# Reasoning / Council: size is the signal, but an UNDECLARED size is not a
57+
# disqualification. /v1/models states no parameter count, so every
58+
# OpenAI-compatible and cloud model arrives with param_b=None — and excluding
59+
# those meant a frontier hosted model could never hold a reasoning role while
60+
# a local 8B could. That is backwards.
61+
#
62+
# This mirrors the decision already made for tool_calling three lines up:
63+
# unknown stays eligible, and `_score` gives it no size credit, so a model
64+
# whose size is KNOWN to qualify always outranks one that merely might.
65+
# Nothing is lost either way — an empty role already fell back to Execution
66+
# and returned the same small model, just without saying so.
67+
undeclared = pb is None
68+
if thinking or undeclared or pb >= _REASON_MIN_B:
5369
roles.append("Reasoning")
54-
55-
# Council: the heaviest models.
56-
if pb is not None and pb >= _COUNCIL_MIN_B:
70+
if undeclared or pb >= _COUNCIL_MIN_B:
5771
roles.append("Council")
5872

5973
if not roles:
@@ -78,6 +92,19 @@ def flatten(scan_results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
7892
rec["api_type"] = rt.get("api_type") or m.get("api_type")
7993
rec["online"] = rt.get("online", True)
8094
rec["roles"] = classify(rec)
95+
# Say on what grounds it qualified. A role held because the size is
96+
# undeclared is not the same claim as one held because the size was
97+
# read and met the bar, and the operator-facing registry should not
98+
# present them as if they were.
99+
rec["role_basis"] = ("declared-capability" if rec.get("param_b") is not None
100+
or rec.get("thinking") or rec.get("vision")
101+
or rec.get("embedding")
102+
else "undeclared-size (eligible, unproven)")
103+
# A runtime that answered but refused us is NOT offline, and saying
104+
# "offline" would send the operator to restart a server that is running.
105+
if rt.get("authorized") is False:
106+
rec["authorized"] = False
107+
rec["unavailable_reason"] = rt.get("reason") or "authentication required"
81108
out.append(rec)
82109
return out
83110

backend/runtime_router.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,28 @@ def route_with_registry(task_or_role: str, registry: Dict[str, Any],
4949
is_role: bool = False) -> Dict[str, Any]:
5050
"""Pick the best model for the task/role from a prebuilt registry. Pure."""
5151
role = task_or_role if is_role else task_to_role(task_or_role)
52+
asked = role
5253
ranked = (registry.get("rankings") or {}).get(role, [])
54+
substituted = False
5355
if not ranked:
54-
# graceful fallback: any Execution model, else any model at all
56+
# Vision and Embedding are physical capabilities, not quality tiers. A
57+
# text model handed an image does not answer worse — it answers wrongly,
58+
# or errors. Refuse instead of substituting; the caller can then say
59+
# "no vision model installed" rather than relay confident nonsense.
60+
try:
61+
from runtime_registry import HARD_CAPABILITY_ROLES
62+
except Exception:
63+
HARD_CAPABILITY_ROLES = ("Vision", "Embedding")
64+
if role in HARD_CAPABILITY_ROLES:
65+
return {"role": role, "model": None, "runtime": None, "url": None,
66+
"api_type": None, "endpoint": None,
67+
"reason": f"no model declares the {role} capability — "
68+
f"substituting a text model here would fabricate, not degrade"}
69+
# Quality-tier roles may fall back, but the substitution is REPORTED.
5570
for fb in ("Execution", "Reasoning", "Utility"):
5671
ranked = (registry.get("rankings") or {}).get(fb, [])
5772
if ranked:
58-
role = fb
73+
role, substituted = fb, True
5974
break
6075
if not ranked:
6176
return {"role": role, "model": None, "runtime": None, "url": None,
@@ -64,9 +79,18 @@ def route_with_registry(task_or_role: str, registry: Dict[str, Any],
6479
api = top.get("api_type", "ollama")
6580
url = (top.get("url") or "").rstrip("/")
6681
endpoint = url + ("/api/chat" if api == "ollama" else "/chat/completions")
67-
return {"role": role, "model": top["id"], "runtime": top["runtime"],
68-
"url": url, "api_type": api, "endpoint": endpoint,
69-
"score": top.get("score"), "alternatives": ranked[1:4]}
82+
out = {"role": role, "model": top["id"], "runtime": top["runtime"],
83+
"url": url, "api_type": api, "endpoint": endpoint,
84+
"score": top.get("score"), "alternatives": ranked[1:4]}
85+
if substituted:
86+
# The caller asked for one class of model and is getting another. Comparing
87+
# `role` against what you asked only works if you remember what you asked,
88+
# so state it outright.
89+
out["requested_role"] = asked
90+
out["substituted"] = True
91+
out["reason"] = (f"no {asked} model available — substituted the best {role} "
92+
f"model; expect lower quality for a {asked} task")
93+
return out
7094

7195

7296
# ── health monitor (Phase 7) ──────────────────────────────────────────────────

backend/runtime_scanner.py

Lines changed: 73 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
import json
2222
import re
23+
import urllib.error
2324
import urllib.request
2425
from typing import Any, Dict, List, Optional
2526

@@ -35,22 +36,48 @@
3536

3637

3738
# ── http helpers ──────────────────────────────────────────────────────────────
38-
def _get(url: str, timeout: float = 3.0) -> Optional[Any]:
39+
# A runtime may sit behind a key: `vllm serve --api-key`, LM Studio with auth on,
40+
# or any hosted OpenAI-compatible endpoint. Sending no Authorization header meant
41+
# such a runtime answered 401 and was filed as OFFLINE — indistinguishable from
42+
# "not running", so the operator was told to start a server that was already up.
43+
# The key travels with the probe; a refusal is now reported as a refusal.
44+
def _headers(api_key: Optional[str] = None) -> Dict[str, str]:
45+
h = {"Content-Type": "application/json"}
46+
if api_key:
47+
h["Authorization"] = f"Bearer {api_key}"
48+
return h
49+
50+
51+
def _request(url: str, timeout: float, api_key: Optional[str] = None,
52+
body: Optional[dict] = None) -> Dict[str, Any]:
53+
"""Returns {data, status, unauthorized, reachable}. Never raises.
54+
55+
`unauthorized` is the point: it separates "the server said no" from "there was
56+
no server", which the previous bare `except: return None` collapsed into one.
57+
"""
58+
data = json.dumps(body).encode() if body is not None else None
59+
req = urllib.request.Request(url, data=data, headers=_headers(api_key))
3960
try:
40-
with urllib.request.urlopen(url, timeout=timeout) as r:
41-
return json.loads(r.read().decode("utf-8", "replace"))
61+
with urllib.request.urlopen(req, timeout=timeout) as r:
62+
return {"data": json.loads(r.read().decode("utf-8", "replace")),
63+
"status": getattr(r, "status", 200),
64+
"unauthorized": False, "reachable": True}
65+
except urllib.error.HTTPError as e:
66+
# An HTTP error means something answered — the runtime IS reachable.
67+
return {"data": None, "status": e.code,
68+
"unauthorized": e.code in (401, 403), "reachable": True}
4269
except Exception:
43-
return None
70+
return {"data": None, "status": None, "unauthorized": False,
71+
"reachable": False}
4472

4573

46-
def _post(url: str, body: dict, timeout: float = 6.0) -> Optional[Any]:
47-
try:
48-
req = urllib.request.Request(url, data=json.dumps(body).encode(),
49-
headers={"Content-Type": "application/json"})
50-
with urllib.request.urlopen(req, timeout=timeout) as r:
51-
return json.loads(r.read().decode("utf-8", "replace"))
52-
except Exception:
53-
return None
74+
def _get(url: str, timeout: float = 3.0, api_key: Optional[str] = None) -> Optional[Any]:
75+
return _request(url, timeout, api_key)["data"]
76+
77+
78+
def _post(url: str, body: dict, timeout: float = 6.0,
79+
api_key: Optional[str] = None) -> Optional[Any]:
80+
return _request(url, timeout, api_key, body=body)["data"]
5481

5582

5683
# ── pure capability parsers (unit-tested) ─────────────────────────────────────
@@ -92,8 +119,8 @@ def caps_from_ollama_show(show: dict) -> Dict[str, Any]:
92119

93120

94121
# ── per-runtime model discovery ───────────────────────────────────────────────
95-
def _ollama_models(base: str) -> List[Dict[str, Any]]:
96-
tags = _get(base.rstrip("/") + "/api/tags")
122+
def _ollama_models(base: str, api_key: Optional[str] = None) -> List[Dict[str, Any]]:
123+
tags = _get(base.rstrip("/") + "/api/tags", api_key=api_key)
97124
out: List[Dict[str, Any]] = []
98125
if not tags:
99126
return out
@@ -107,7 +134,7 @@ def _ollama_models(base: str) -> List[Dict[str, Any]]:
107134
"context": None, "tool_calling": None, "vision": None,
108135
"thinking": None, "embedding": None, "api_type": "ollama",
109136
}
110-
show = _post(base.rstrip("/") + "/api/show", {"model": mid})
137+
show = _post(base.rstrip("/") + "/api/show", {"model": mid}, api_key=api_key)
111138
if show:
112139
c = caps_from_ollama_show(show)
113140
rec.update({k: c[k] for k in ("context", "tool_calling", "vision",
@@ -121,13 +148,13 @@ def _ollama_models(base: str) -> List[Dict[str, Any]]:
121148
return out
122149

123150

124-
def _openai_models(base: str) -> List[Dict[str, Any]]:
125-
data = _get(base.rstrip("/") + "/models")
151+
def _openai_models(base: str, api_key: Optional[str] = None) -> List[Dict[str, Any]]:
152+
data = _get(base.rstrip("/") + "/models", api_key=api_key)
126153
out: List[Dict[str, Any]] = []
127154
if not data:
128155
return out
129156
# llama.cpp exposes richer metadata at /props (single loaded model)
130-
props = _get(base.rstrip("/") + "/props") or {}
157+
props = _get(base.rstrip("/") + "/props", api_key=api_key) or {}
131158
pctx = None
132159
try:
133160
pctx = int((props.get("default_generation_settings") or {}).get("n_ctx")
@@ -152,20 +179,40 @@ def _openai_models(base: str) -> List[Dict[str, Any]]:
152179

153180

154181
def scan_runtime(probe: Dict[str, str]) -> Dict[str, Any]:
155-
"""Probe one runtime → {runtime,url,online,api_type,models}. Never raises."""
182+
"""Probe one runtime → {runtime,url,online,api_type,models,...}. Never raises.
183+
184+
`probe` may carry an `api_key`; a runtime that answers 401/403 is reported as
185+
online-but-unauthorized rather than offline, because telling the operator to
186+
restart a server that is running and simply refused the request wastes their
187+
time and hides the real fix.
188+
"""
156189
url, api = probe["url"], probe.get("api_type", "openai")
190+
key = probe.get("api_key") or None
191+
unauthorized = False
157192
try:
158193
if api == "ollama":
159-
models = _ollama_models(url)
160-
online = _get(url.rstrip("/") + "/api/tags") is not None
194+
probe_r = _request(url.rstrip("/") + "/api/tags", 3.0, key)
195+
models = _ollama_models(url, key) if not probe_r["unauthorized"] else []
161196
else:
162-
models = _openai_models(url)
163-
online = (_get(url.rstrip("/") + "/models") is not None
164-
or _get(url.rstrip("/") + "/health") is not None)
197+
probe_r = _request(url.rstrip("/") + "/models", 3.0, key)
198+
if not probe_r["reachable"]:
199+
probe_r = _request(url.rstrip("/") + "/health", 3.0, key)
200+
models = _openai_models(url, key) if not probe_r["unauthorized"] else []
201+
unauthorized = bool(probe_r["unauthorized"])
202+
# Reachable is the honest signal: a refusal proves something is listening.
203+
online = probe_r["reachable"] or bool(models)
165204
except Exception:
166205
models, online = [], False
167-
return {"runtime": probe["runtime"], "url": url, "api_type": api,
168-
"online": bool(online or models), "models": models}
206+
out: Dict[str, Any] = {
207+
"runtime": probe["runtime"], "url": url, "api_type": api,
208+
"online": bool(online), "models": models,
209+
}
210+
if unauthorized:
211+
out["authorized"] = False
212+
out["reason"] = ("the runtime is reachable but refused the request (401/403) — "
213+
"it needs an API key. Add one to this connection; it is "
214+
"running, not down.")
215+
return out
169216

170217

171218
def scan(extra_probes: Optional[List[Dict[str, str]]] = None,

0 commit comments

Comments
 (0)