node and account names

This commit is contained in:
Dobromir Popov
2026-07-08 21:33:42 +03:00
parent 52629d7762
commit 1e44e8e578
7 changed files with 275 additions and 8 deletions

View File

@@ -68,6 +68,7 @@ def _run_node(cfg: dict) -> None:
tracker_source_disabled=bool(cfg.get("tracker_source_disabled", False)),
torch_threads=cfg.get("torch_threads"),
torch_interop_threads=cfg.get("torch_interop_threads"),
node_name=cfg.get("node_name"),
)
except Exception as exc:
print(f"\nERROR: {exc}", file=sys.stderr, flush=True)
@@ -157,6 +158,8 @@ def _cmd_default(args) -> int:
overrides["host"] = args.host
if args.advertise_host:
overrides["advertise_host"] = args.advertise_host
if getattr(args, "node_name", None):
overrides["node_name"] = args.node_name
if args.route_timeout != 30.0:
overrides["route_timeout"] = args.route_timeout
if getattr(args, "memory", None) is not None:
@@ -246,6 +249,8 @@ def _cmd_start(args) -> int:
cfg["wallet_path"] = args.wallet
if args.download_dir:
cfg["download_dir"] = args.download_dir
if getattr(args, "node_name", None):
cfg["node_name"] = args.node_name
# Legacy start: just run without the dashboard (keep original blocking loop)
from .startup import run_startup
@@ -270,6 +275,7 @@ def _cmd_start(args) -> int:
tracker_source_disabled=getattr(args, "tracker_source_disabled", False),
torch_threads=getattr(args, "torch_threads", None),
torch_interop_threads=getattr(args, "torch_interop_threads", None),
node_name=cfg.get("node_name"),
)
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr, flush=True)
@@ -315,6 +321,7 @@ def main() -> None:
parser.add_argument("--port", type=int, metavar="N", help="Port to listen on")
parser.add_argument("--host", metavar="ADDR", help="Interface to bind (default 0.0.0.0)")
parser.add_argument("--advertise-host", metavar="ADDR", help="Host/IP advertised to the tracker")
parser.add_argument("--node-name", metavar="NAME", help="Friendly display name shown on the tracker dashboard")
parser.add_argument("--route-timeout", type=float, metavar="SEC", default=30.0,
help="Seconds to wait for tracker route lookup (default 30)")
parser.add_argument("--memory", type=int, metavar="MB", default=None,
@@ -350,6 +357,7 @@ def main() -> None:
start_cmd.add_argument("--quantization", choices=["auto", "bfloat16", "int8", "nf4", "bf16"], default="auto")
start_cmd.add_argument("--host", default="0.0.0.0")
start_cmd.add_argument("--advertise-host")
start_cmd.add_argument("--node-name", help="Friendly display name shown on the tracker dashboard")
start_cmd.add_argument("--tracker-mode", action="store_true")
start_cmd.add_argument("--tracker-url", default=None)
start_cmd.add_argument("--wallet")

View File

@@ -610,6 +610,15 @@ def _warn_virtual_network_ip(ip: str | None) -> None:
pass
def _registration_display_fields(node_name: str | None) -> dict[str, str]:
if not node_name:
return {}
name = node_name.strip()
if not name:
return {}
return {"friendly_name": name}
def run_startup(
tracker_url: str,
port: int = 0,
@@ -630,6 +639,7 @@ def run_startup(
tracker_source_disabled: bool = False,
torch_threads: int | None = None,
torch_interop_threads: int | None = None,
node_name: str | None = None,
) -> StubNodeServer | TorchNodeServer:
"""Execute the full startup sequence and return a running node server.
@@ -646,6 +656,7 @@ def run_startup(
tracker_url = tracker_url.rstrip("/")
relay_url = _discover_relay_url(tracker_url)
display_fields = _registration_display_fields(node_name)
if max_loaded_shards < 1:
raise ValueError("--max-shards must be at least 1")
@@ -871,6 +882,7 @@ def run_startup(
),
**registration_capabilities,
**relay_fields,
**display_fields,
}
tracker_node_id = _register_with_tracker(
tracker_url, reg_payload, node, _node_start_time,
@@ -1019,6 +1031,7 @@ def run_startup(
),
**registration_capabilities,
**relay_fields,
**display_fields,
}
tracker_node_id = _register_with_tracker(
tracker_url, auto_reg_payload, node, _node_start_time,
@@ -1186,6 +1199,7 @@ def run_startup(
"model_metadata": model_metadata_for(hf_repo, total_layers, cache_dir=shard_path),
**registration_capabilities,
**relay_fields,
**display_fields,
}
tracker_node_id = _register_with_tracker(
tracker_url, reg_payload, node, _node_start_time,
@@ -1245,6 +1259,7 @@ def run_startup(
"managed_assignment": not user_pinned_shard,
**registration_capabilities,
**relay_fields,
**display_fields,
}
try:
reg_resp = _post_json(

View File

@@ -27,9 +27,24 @@ DEFAULT_ACCOUNTS_DB_PATH = "accounts.sqlite"
SESSION_TTL = 7 * 86400.0 # seconds
PBKDF2_ITERATIONS = 200_000
MIN_PASSWORD_LENGTH = 8
_MAX_NICKNAME_LENGTH = 64
API_KEY_PREFIX = "sk-mesh-"
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
_UNSET = object()
def _normalize_nickname(value: object) -> str | None:
if value is None:
return None
if not isinstance(value, str):
raise ValueError("nickname must be a string")
nickname = value.strip()
if not nickname:
return None
if len(nickname) > _MAX_NICKNAME_LENGTH:
raise ValueError(f"nickname must be at most {_MAX_NICKNAME_LENGTH} characters")
return nickname
def _hash_password(password: str, salt: str) -> str:
@@ -64,6 +79,7 @@ class AccountStore:
email: str | None = None,
wallet: str | None = None,
password: str,
nickname: str | None = None,
) -> dict:
"""Create an account. The first account becomes the admin.
@@ -77,6 +93,7 @@ class AccountStore:
raise ValueError("invalid email address")
if len(password or "") < MIN_PASSWORD_LENGTH:
raise ValueError(f"password must be at least {MIN_PASSWORD_LENGTH} characters")
nickname = _normalize_nickname(nickname)
with self._lock:
for identifier in filter(None, (email, wallet)):
if identifier.lower() in self._by_identifier:
@@ -91,11 +108,30 @@ class AccountStore:
"role": "admin" if not self._accounts else "user",
"password_hash": _hash_password(password, salt),
"salt": salt,
"nickname": nickname,
"ts": time.time(),
}
self._apply_locked(event)
return self._public_view(self._accounts[event["account_id"]])
def update_profile(self, account_id: str, *, nickname: str | None = _UNSET) -> dict:
"""Update display fields for an account. Pass nickname=None to clear."""
if nickname is not _UNSET:
nickname = _normalize_nickname(nickname)
with self._lock:
if account_id not in self._accounts:
raise ValueError("unknown account")
event = {
"id": f"profile-{uuid.uuid4().hex}",
"type": "update_profile",
"account_id": account_id,
"ts": time.time(),
}
if nickname is not _UNSET:
event["nickname"] = nickname
self._apply_locked(event)
return self._public_view(self._accounts[account_id])
def verify_login(self, identifier: str, password: str) -> dict | None:
"""Return the public account view when credentials match, else None."""
with self._lock:
@@ -196,6 +232,7 @@ class AccountStore:
"account_id": record["account_id"],
"email": record.get("email"),
"wallet": record.get("wallet"),
"nickname": record.get("nickname"),
"role": record["role"],
"created_ts": record.get("ts", 0.0),
}
@@ -249,11 +286,19 @@ class AccountStore:
"role": event.get("role", "user"),
"password_hash": event["password_hash"],
"salt": event["salt"],
"nickname": event.get("nickname"),
"ts": float(event.get("ts", 0.0)),
}
self._accounts[account_id] = record
for identifier in filter(None, (record["email"], record["wallet"])):
self._by_identifier.setdefault(identifier.lower(), account_id)
elif etype == "update_profile":
account_id = event["account_id"]
record = self._accounts.get(account_id)
if record is None:
return
if "nickname" in event:
record["nickname"] = event.get("nickname")
elif etype == "create_key":
api_key = event["api_key"]
if api_key not in self._revoked_keys:

View File

@@ -280,6 +280,41 @@ const copies = v => (v === null || v === undefined) ? "?" : Number(v).toFixed(2)
const hp = v => (v === null || v === undefined) ? "?HP" : `${copies(v)}HP`;
const short = (s, n=14) => { s = String(s); return s.length > n ? s.slice(0, 6) + "…" + s.slice(-5) : s; };
function accountDisplayName(account) {
if (!account) return "?";
const nickname = (account.nickname || "").trim();
if (nickname) return nickname;
return account.email || account.wallet || account.account_id || "?";
}
function buildNodeNameMap(map) {
const byId = new Map();
for (const node of (map && map.nodes) || []) {
const name = (node.friendly_name || "").trim();
if (node.node_id && name) byId.set(node.node_id, name);
}
return byId;
}
function nodeDisplayName(nodeOrId, nameMap) {
if (nodeOrId && typeof nodeOrId === "object") {
const friendly = (nodeOrId.friendly_name || "").trim();
if (friendly) return friendly;
return short(nodeOrId.node_id || "?");
}
const friendly = nameMap && nameMap.get(nodeOrId);
return friendly || short(nodeOrId || "?");
}
function nodeDisplayCell(node) {
const label = esc(nodeDisplayName(node));
const friendly = (node.friendly_name || "").trim();
if (friendly && node.node_id) {
return `<span title="${esc(node.node_id)}">${label}</span>`;
}
return label;
}
function modelAliasKey(value) {
if (!value) return "";
const text = String(value).trim();
@@ -398,7 +433,7 @@ function renderNodes(map) {
html += table(["node", "shard", "tps (1h)", "queue", "served"], group.map(n => {
const modelStats = (n.throughput && (n.throughput[n.hf_repo] || n.throughput[n.model])) || {};
return [
esc(short(n.node_id || "?")),
nodeDisplayCell(n),
esc(`${n.shard_start ?? "?"}-${n.shard_end ?? "?"}`),
`<span class="num">${esc(tps(modelStats.tokens_per_sec_last_hour))}</span>`,
esc(String((n.stats && n.stats.queue_depth) ?? 0)),
@@ -475,11 +510,12 @@ function renderStats(stats) {
function renderThroughputHtml(stats) {
const nodes = (stats && stats.nodes) || {};
const nameMap = buildNodeNameMap(lastNetworkMap);
const rows = [];
for (const [nodeId, nodeStats] of Object.entries(nodes)) {
for (const [model, s] of Object.entries((nodeStats && nodeStats.models) || {})) {
rows.push([
esc(short(nodeId)),
esc(nodeDisplayName(nodeId, nameMap)),
esc(short(model, 24)),
`<span class="num">${esc(tps(s.tokens_per_sec_last_hour))}</span>`,
`<span class="num">${esc(String(s.sample_count_last_hour ?? 0))}</span>`,
@@ -612,6 +648,7 @@ function renderRouting(routing) {
return;
}
const cfg = (routing && routing.config) || {};
const nameMap = buildNodeNameMap(lastNetworkMap);
let html = `<div class="dim" style="margin-bottom:6px">` +
`explore share: <b>${esc(String(cfg.explore_share ?? "?"))}</b> · ` +
`traffic ∝ tps^<b>${esc(String(cfg.weight_alpha ?? "?"))}</b> · ` +
@@ -621,7 +658,7 @@ function renderRouting(routing) {
html += `<div style="margin-top:6px"><b>${esc(model)}</b> ` +
`<span class="dim">(epoch ${esc(String(info.epoch ?? 0))} · ${routes.length} route${routes.length === 1 ? "" : "s"})</span></div>`;
html += table(["route", "tps", "coeff", "share", "samples", "status"], routes.map(r => {
const hops = (r.hops || []).map(h => `${short(h.node_id, 12)}[${h.shard}]`).join(" → ");
const hops = (r.hops || []).map(h => `${nodeDisplayName(h.node_id, nameMap)}[${h.shard}]`).join(" → ");
const statusCls = r.status === "proven" ? "ok" : r.status === "stale" ? "warn" : "dim";
const coeff = (r.coefficient === null || r.coefficient === undefined)
? "—" : Number(r.coefficient).toFixed(2) + "×";
@@ -1259,7 +1296,8 @@ function renderAuthForms(errorMsg) {
`<a class="${authTab === name ? "active" : ""}" onclick="switchAuthTab('${name}')">${label}</a>`;
const identityFields =
'<input id="auth-email" type="email" placeholder="email (or leave blank)">' +
'<input id="auth-wallet" type="text" placeholder="wallet address (or leave blank)">';
'<input id="auth-wallet" type="text" placeholder="wallet address (or leave blank)">' +
'<input id="auth-nickname" type="text" placeholder="nickname (optional)">';
const form = authTab === "login"
? '<input id="auth-identifier" type="text" placeholder="email or wallet address">' +
'<input id="auth-password" type="password" placeholder="password">' +
@@ -1281,9 +1319,10 @@ function switchAuthTab(name) { authTab = name; renderAuthForms(); }
async function doRegister() {
const email = $("auth-email").value.trim();
const wallet = $("auth-wallet").value.trim();
const nickname = $("auth-nickname").value.trim();
const password = $("auth-password").value;
const r = await apiCall("/v1/auth/register", "POST",
{ email: email || null, wallet: wallet || null, password });
{ email: email || null, wallet: wallet || null, nickname: nickname || null, password });
if (!r.ok) { renderAuthForms(r.data.error || "registration failed"); return; }
setSession(r.data.session_token);
await renderAccountPanel();
@@ -1382,6 +1421,13 @@ function renderChatAuthHint() {
}
}
async function saveNickname() {
const nickname = $("account-nickname").value.trim();
const r = await apiCall("/v1/account/profile", "POST", { nickname: nickname || null });
if (!r.ok) alert(r.data.error || "nickname update failed");
else await renderAccountPanel();
}
async function renderAccountPanel() {
const r = await apiCall("/v1/account");
if (r.status === 404) { // accounts disabled on this tracker
@@ -1397,10 +1443,14 @@ async function renderAccountPanel() {
const { account, api_keys, balances, total_balance, usage, topup_amount } = r.data;
accountApiKeys = Array.isArray(api_keys) ? api_keys.slice() : [];
accountUsageRecords = (usage && (usage.records || usage.recent)) || [];
const who = account.email || account.wallet || account.account_id;
const who = accountDisplayName(account);
let html =
`<div><b>${esc(who)}</b> <span class="pill">${esc(account.role)}</span> ` +
`<button class="small" style="float:right" onclick="doLogout()">log out</button></div>` +
`<div style="margin:6px 0;display:flex;gap:8px;align-items:center;flex-wrap:wrap">` +
`<label class="dim" style="margin:0">nickname</label>` +
`<input id="account-nickname" type="text" value="${esc(account.nickname || "")}" placeholder="display name" style="min-width:180px">` +
`<button class="small" type="button" onclick="saveNickname()">save</button></div>` +
`<div style="margin:6px 0">balance: <b class="${total_balance > 0 ? "ok" : "bad"}">` +
`${usdt(total_balance)}</b> USDT · requests: <b>${usage.requests}</b> · ` +
`tokens: <b>${usage.total_tokens}</b> · spent: <b>${usdt(usage.total_cost)}</b> USDT</div>`;
@@ -1617,7 +1667,7 @@ async function renderAdminPanel() {
const rows = (r.data.accounts || []).map(a => {
const balance = Object.values(a.balances || {}).reduce((x, y) => x + y, 0);
return [
esc(short(a.email || a.wallet || a.account_id, 24)),
esc(short(accountDisplayName(a), 24)),
`<span class="pill">${esc(a.role)}</span>`,
String((a.api_keys || []).length),
`<span class="num ${balance > 0 ? "ok" : "bad"}">${usdt(balance)}</span>`,

View File

@@ -570,7 +570,7 @@ class _NodeEntry:
"benchmark_tokens_per_sec", "quantization", "managed_assignment",
"model_tokens_per_sec",
"pending_directives", "last_heartbeat", "tracker_mode",
"relay_addr", "cert_fingerprint", "peer_id",
"relay_addr", "cert_fingerprint", "peer_id", "friendly_name",
# heartbeat stats (reported by node, cumulative)
"total_requests", "failed_requests", "queue_depth", "proxy_inflight", "uptime_seconds",
"current_requests",
@@ -606,6 +606,7 @@ class _NodeEntry:
relay_addr: str | None = None,
cert_fingerprint: str | None = None,
peer_id: str | None = None,
friendly_name: str | None = None,
) -> None:
self.node_id = node_id
self.endpoint = endpoint
@@ -632,6 +633,7 @@ class _NodeEntry:
self.relay_addr = relay_addr
self.cert_fingerprint = cert_fingerprint
self.peer_id = peer_id
self.friendly_name = friendly_name
self.pending_directives: list[dict] = []
self.last_heartbeat: float = time.monotonic()
self.total_requests: int = 0
@@ -2231,6 +2233,22 @@ def _registration_ban_error(contracts: Any | None, wallet_address: str | None) -
return None
_MAX_FRIENDLY_NAME_LENGTH = 64
def _normalize_friendly_name(value: object) -> str | None:
if value is None:
return None
if not isinstance(value, str):
raise ValueError("friendly_name must be a string")
name = value.strip()
if not name:
return None
if len(name) > _MAX_FRIENDLY_NAME_LENGTH:
raise ValueError(f"friendly_name must be at most {_MAX_FRIENDLY_NAME_LENGTH} characters")
return name
def _tracker_log(
server: "_TrackerHTTPServer",
level: str,
@@ -2606,6 +2624,9 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
if self.path == "/v1/auth/logout":
self._handle_auth_logout()
return
if self.path == "/v1/account/profile":
self._handle_account_profile()
return
if self.path == "/v1/account/keys":
self._handle_account_key_create()
return
@@ -2923,6 +2944,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
"nodes": [
{
"node_id": node.node_id,
"friendly_name": node.friendly_name,
"endpoint": node.endpoint,
"relay_addr": node.relay_addr,
"peer_id": node.peer_id,
@@ -4001,6 +4023,11 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
relay_addr = body.get("relay_addr") or None
cert_fingerprint = body.get("cert_fingerprint") or None
peer_id = body.get("peer_id") or None
try:
friendly_name = _normalize_friendly_name(body.get("friendly_name"))
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
node_id = _node_id_for_registration(
endpoint,
@@ -4035,6 +4062,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
relay_addr=relay_addr,
cert_fingerprint=cert_fingerprint,
peer_id=peer_id,
friendly_name=friendly_name,
)
with server.lock:
self._purge_expired_nodes()
@@ -4053,6 +4081,12 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
for eid in stale_ids:
old = server.registry.pop(eid)
stale_entries.append((eid, old))
if friendly_name is None:
for _eid, old in stale_entries:
if old.friendly_name:
friendly_name = old.friendly_name
break
entry.friendly_name = friendly_name
is_topology_change = node_id not in stale_ids or any(
(old.shard_start, old.shard_end) != (entry.shard_start, entry.shard_end)
for eid, old in stale_entries
@@ -4162,6 +4196,12 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
entry.uptime_seconds = float(body["uptime_seconds"])
if "status" in body and body["status"] in ("ready", "loading"):
entry.status = body["status"]
if "friendly_name" in body:
try:
entry.friendly_name = _normalize_friendly_name(body.get("friendly_name"))
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
directives = list(entry.pending_directives)
entry.pending_directives.clear()
new_assignment = entry.pending_new_assignment
@@ -4443,6 +4483,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
email=body.get("email"),
wallet=body.get("wallet"),
password=str(body.get("password") or ""),
nickname=body.get("nickname"),
)
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
@@ -4518,6 +4559,27 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
"topup_amount": server.devnet_topup_amount if server.billing is not None else 0.0,
})
def _handle_account_profile(self):
accounts = self._require_accounts()
if accounts is None:
return
account = self._session_account()
if account is None:
self._send_json(401, {"error": "login required"})
return
body = self._read_json_body()
if body is None:
return
try:
updated = accounts.update_profile(
account["account_id"],
nickname=body.get("nickname"),
)
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
self._send_json(200, {"account": updated})
def _handle_account_key_create(self):
accounts = self._require_accounts()
if accounts is None:
@@ -6076,6 +6138,10 @@ class TrackerServer:
shard_end = int(payload["shard_end"]) if payload.get("shard_end") is not None else None
except (TypeError, ValueError):
return
try:
friendly_name = _normalize_friendly_name(payload.get("friendly_name"))
except ValueError:
friendly_name = None
entry = _NodeEntry(
node_id=node_id,
endpoint=endpoint.rstrip("/"),
@@ -6095,6 +6161,7 @@ class TrackerServer:
if isinstance(payload.get("downloaded_models"), list)
else None
),
friendly_name=_normalize_friendly_name(payload.get("friendly_name")),
)
with self._lock:
self._registry[node_id] = entry

View File

@@ -48,6 +48,36 @@ def test_register_rejects_duplicate_identifiers():
store.register(email="DUP@example.com", password="other-secret")
def test_register_and_update_nickname():
store = AccountStore()
account = store.register(
email="nick@example.com",
password="secret-123",
nickname=" Alpha ",
)
assert account["nickname"] == "Alpha"
updated = store.update_profile(account["account_id"], nickname="Beta")
assert updated["nickname"] == "Beta"
cleared = store.update_profile(account["account_id"], nickname=None)
assert cleared["nickname"] is None
def test_nickname_replicates_across_stores():
leader = AccountStore()
follower = AccountStore()
account = leader.register(
email="nick@example.com",
password="secret-123",
nickname="HiveNick",
)
leader.update_profile(account["account_id"], nickname="Renamed")
events, _ = leader.events_since(0)
follower.apply_events(events)
view = follower.get_account(account["account_id"])
assert view is not None
assert view["nickname"] == "Renamed"
def test_login_by_email_or_wallet():
store = AccountStore()
account = store.register(
@@ -168,6 +198,27 @@ def test_register_login_and_account_view(account_tracker):
assert me["usage"]["requests"] == 0
def test_account_nickname_register_and_profile_update(account_tracker):
url, _ = account_tracker
reg = _call(f"{url}/v1/auth/register", "POST", {
"email": "nick@example.com",
"password": "secret-123",
"nickname": "Operator",
})
assert reg["account"]["nickname"] == "Operator"
updated = _call(
f"{url}/v1/account/profile",
"POST",
{"nickname": "Renamed"},
token=reg["session_token"],
)
assert updated["account"]["nickname"] == "Renamed"
me = _call(f"{url}/v1/account", token=reg["session_token"])
assert me["account"]["nickname"] == "Renamed"
def test_login_sets_cookie_and_cookie_auth_survives_tracker_restart(tmp_path):
accounts_db = str(tmp_path / "accounts.db")
tracker = TrackerServer(

View File

@@ -52,6 +52,34 @@ def test_dashboard_chat_uses_streaming_fetch():
assert 'console.error("chat stream failed", err)' in html
def test_network_map_includes_node_friendly_name():
tracker = TrackerServer()
port = tracker.start()
try:
body = json.dumps({
"endpoint": "http://127.0.0.1:9010",
"model": "stub-model",
"shard_start": 0,
"shard_end": 3,
"hardware_profile": {},
"friendly_name": "Kitchen GPU",
}).encode()
req = urllib.request.Request(
f"http://127.0.0.1:{port}/v1/nodes/register",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
urllib.request.urlopen(req).read()
network = json.loads(
urllib.request.urlopen(f"http://127.0.0.1:{port}/v1/network/map").read()
)
finally:
tracker.stop()
assert network["nodes"][0]["friendly_name"] == "Kitchen GPU"
def test_dashboard_chat_model_selector_shows_health_and_speed():
tracker = TrackerServer()
port = tracker.start()
@@ -64,6 +92,9 @@ def test_dashboard_chat_model_selector_shows_health_and_speed():
assert "chatModelHealthHp" in html
assert "modelServedCopiesFromMap" in html
assert "nodeDisplayName" in html
assert "accountDisplayName" in html
assert "saveNickname" in html
assert "chatModelTypicalTps" in html
assert "chatModelOptionLabel" in html
assert "findRoutingForModel" in html