This commit is contained in:
Dobromir Popov
2026-07-08 22:53:07 +02:00
10 changed files with 852 additions and 168 deletions

View File

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

View File

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

View File

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

View File

@@ -453,13 +453,12 @@ class BillingLedger:
with self._lock: with self._lock:
return self._node_pending.get(wallet, 0.0) return self._node_pending.get(wallet, 0.0)
def usage_for(self, api_keys: list[str], *, recent_limit: int | None = None) -> dict: def usage_totals_for(self, api_keys: list[str]) -> dict:
"""Aggregate charge history for a set of API keys (dashboard view).""" """Aggregate charge totals without per-request records (dashboard summary)."""
keys = set(api_keys) keys = set(api_keys)
requests = 0 requests = 0
total_tokens = 0 total_tokens = 0
total_cost = 0.0 total_cost = 0.0
records: list[dict] = []
with self._lock: with self._lock:
for event in self._event_log: for event in self._event_log:
if event.get("type") != "charge" or event.get("api_key") not in keys: if event.get("type") != "charge" or event.get("api_key") not in keys:
@@ -467,6 +466,20 @@ class BillingLedger:
requests += 1 requests += 1
total_tokens += int(event.get("total_tokens", 0)) total_tokens += int(event.get("total_tokens", 0))
total_cost += float(event.get("cost", 0.0)) total_cost += float(event.get("cost", 0.0))
return {
"requests": requests,
"total_tokens": total_tokens,
"total_cost": total_cost,
}
def usage_for(self, api_keys: list[str], *, recent_limit: int | None = None) -> dict:
"""Aggregate charge history for a set of API keys (dashboard view)."""
keys = set(api_keys)
records: list[dict] = []
with self._lock:
for event in self._event_log:
if event.get("type") != "charge" or event.get("api_key") not in keys:
continue
records.append({ records.append({
"api_key": event["api_key"], "api_key": event["api_key"],
"model": event.get("model"), "model": event.get("model"),
@@ -476,9 +489,9 @@ class BillingLedger:
}) })
recent = records[-recent_limit:] if recent_limit is not None else records recent = records[-recent_limit:] if recent_limit is not None else records
return { return {
"requests": requests, "requests": len(records),
"total_tokens": total_tokens, "total_tokens": sum(int(r.get("total_tokens", 0)) for r in records),
"total_cost": total_cost, "total_cost": sum(float(r.get("cost", 0.0)) for r in records),
"records": records, "records": records,
"recent": recent, "recent": recent,
} }

View File

@@ -219,6 +219,7 @@
<h1>meshnet tracker</h1> <h1>meshnet tracker</h1>
<span class="meta" id="self-url"></span> <span class="meta" id="self-url"></span>
<span class="meta" id="refreshed"></span> <span class="meta" id="refreshed"></span>
<button class="small" id="refresh-btn" type="button" onclick="refreshActiveTab(true)" style="margin-left:auto">refresh</button>
</header> </header>
<nav class="dashboard-tabs" aria-label="Dashboard sections"> <nav class="dashboard-tabs" aria-label="Dashboard sections">
<button id="tab-overview" class="active" onclick="switchDashboardTab('overview')">Overview</button> <button id="tab-overview" class="active" onclick="switchDashboardTab('overview')">Overview</button>
@@ -277,9 +278,44 @@ const esc = s => String(s).replace(/[&<>"]/g,
const usdt = v => (Math.round(v * 1e6) / 1e6).toFixed(6); const usdt = v => (Math.round(v * 1e6) / 1e6).toFixed(6);
const tps = v => (v === null || v === undefined) ? "?" : (Math.round(v * 10) / 10).toFixed(1); const tps = v => (v === null || v === undefined) ? "?" : (Math.round(v * 10) / 10).toFixed(1);
const copies = v => (v === null || v === undefined) ? "?" : Number(v).toFixed(2); const copies = v => (v === null || v === undefined) ? "?" : Number(v).toFixed(2);
const hp = v => (v === null || v === undefined) ? "?HP" : `${Number(v).toFixed(1)}HP`; 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; }; 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) { function modelAliasKey(value) {
if (!value) return ""; if (!value) return "";
const text = String(value).trim(); const text = String(value).trim();
@@ -317,6 +353,102 @@ function resolveModelGroup(node, aliasMap) {
return aliasMap.get(key) || key || "?"; return aliasMap.get(key) || key || "?";
} }
function modelLookupKeys(model) {
const keys = new Set();
for (const value of [model.id, model.name, model.hf_repo, ...(model.aliases || [])]) {
if (!value) continue;
keys.add(String(value));
keys.add(modelAliasKey(value));
}
return keys;
}
function modelServedCopiesFromMap(map, model) {
const nodes = (map && map.nodes) || [];
if (!nodes.length) return model.servedCopies ?? 0;
const aliasMap = buildModelAliasMap(map);
const targetKeys = modelLookupKeys(model);
let best = model.servedCopies ?? 0;
for (const node of nodes) {
const nodeKeys = [resolveModelGroup(node, aliasMap), node.hf_repo, node.model]
.filter(Boolean)
.flatMap(value => [String(value), modelAliasKey(value)]);
if (!nodeKeys.some(key => targetKeys.has(key))) continue;
const served = node.model_supply && node.model_supply.served_model_copies;
if (served !== null && served !== undefined) best = Math.max(best, served);
}
return best;
}
const panelSig = {};
let pendingChatModelRefresh = false;
function stableSig(value) {
return JSON.stringify(value);
}
function renderIfChanged(panelId, data, renderFn) {
const sig = stableSig(data);
if (panelSig[panelId] === sig) return false;
panelSig[panelId] = sig;
renderFn(data);
return true;
}
function isEditing(idOrEl) {
const el = typeof idOrEl === "string" ? $(idOrEl) : idOrEl;
if (!el) return false;
const active = document.activeElement;
return active === el || (active && el.contains(active));
}
function refreshBlocked() {
return selectionActive()
|| chatBusy
|| isEditing("account-nickname")
|| isEditing("chat-prompt")
|| isEditing("chat-model");
}
function syncKeyedList(container, items, keyFn, sigFn, createFn, patchFn) {
if (!container) return;
const sig = stableSig(items.map(item => sigFn(item)));
if (container.dataset.listSig === sig) return;
container.dataset.listSig = sig;
if (!items.length) {
container.className = "chat-session-list empty-state";
container.replaceChildren(document.createTextNode("No chats yet"));
return;
}
container.className = "chat-session-list";
const existing = new Map();
for (const child of container.querySelectorAll("[data-session-id]")) {
existing.set(child.dataset.sessionId, child);
}
const seen = new Set();
for (const item of items) {
const key = keyFn(item);
seen.add(key);
let el = existing.get(key);
if (!el) {
el = createFn(item);
} else {
patchFn(el, item);
}
container.appendChild(el);
}
for (const [key, el] of existing) {
if (!seen.has(key)) el.remove();
}
}
function chatModelsSignature() {
return availableModels.map(model => ({
id: model.id,
label: chatModelOptionLabel(model, lastRouting),
}));
}
async function fetchJson(path) { async function fetchJson(path) {
try { try {
const headers = {}; const headers = {};
@@ -371,7 +503,7 @@ function renderNodes(map) {
html += table(["node", "shard", "tps (1h)", "queue", "served"], group.map(n => { html += table(["node", "shard", "tps (1h)", "queue", "served"], group.map(n => {
const modelStats = (n.throughput && (n.throughput[n.hf_repo] || n.throughput[n.model])) || {}; const modelStats = (n.throughput && (n.throughput[n.hf_repo] || n.throughput[n.model])) || {};
return [ return [
esc(short(n.node_id || "?")), nodeDisplayCell(n),
esc(`${n.shard_start ?? "?"}-${n.shard_end ?? "?"}`), esc(`${n.shard_start ?? "?"}-${n.shard_end ?? "?"}`),
`<span class="num">${esc(tps(modelStats.tokens_per_sec_last_hour))}</span>`, `<span class="num">${esc(tps(modelStats.tokens_per_sec_last_hour))}</span>`,
esc(String((n.stats && n.stats.queue_depth) ?? 0)), esc(String((n.stats && n.stats.queue_depth) ?? 0)),
@@ -448,11 +580,12 @@ function renderStats(stats) {
function renderThroughputHtml(stats) { function renderThroughputHtml(stats) {
const nodes = (stats && stats.nodes) || {}; const nodes = (stats && stats.nodes) || {};
const nameMap = buildNodeNameMap(lastNetworkMap);
const rows = []; const rows = [];
for (const [nodeId, nodeStats] of Object.entries(nodes)) { for (const [nodeId, nodeStats] of Object.entries(nodes)) {
for (const [model, s] of Object.entries((nodeStats && nodeStats.models) || {})) { for (const [model, s] of Object.entries((nodeStats && nodeStats.models) || {})) {
rows.push([ rows.push([
esc(short(nodeId)), esc(nodeDisplayName(nodeId, nameMap)),
esc(short(model, 24)), esc(short(model, 24)),
`<span class="num">${esc(tps(s.tokens_per_sec_last_hour))}</span>`, `<span class="num">${esc(tps(s.tokens_per_sec_last_hour))}</span>`,
`<span class="num">${esc(String(s.sample_count_last_hour ?? 0))}</span>`, `<span class="num">${esc(String(s.sample_count_last_hour ?? 0))}</span>`,
@@ -585,6 +718,7 @@ function renderRouting(routing) {
return; return;
} }
const cfg = (routing && routing.config) || {}; const cfg = (routing && routing.config) || {};
const nameMap = buildNodeNameMap(lastNetworkMap);
let html = `<div class="dim" style="margin-bottom:6px">` + let html = `<div class="dim" style="margin-bottom:6px">` +
`explore share: <b>${esc(String(cfg.explore_share ?? "?"))}</b> · ` + `explore share: <b>${esc(String(cfg.explore_share ?? "?"))}</b> · ` +
`traffic ∝ tps^<b>${esc(String(cfg.weight_alpha ?? "?"))}</b> · ` + `traffic ∝ tps^<b>${esc(String(cfg.weight_alpha ?? "?"))}</b> · ` +
@@ -594,7 +728,7 @@ function renderRouting(routing) {
html += `<div style="margin-top:6px"><b>${esc(model)}</b> ` + 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>`; `<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 => { 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 statusCls = r.status === "proven" ? "ok" : r.status === "stale" ? "warn" : "dim";
const coeff = (r.coefficient === null || r.coefficient === undefined) const coeff = (r.coefficient === null || r.coefficient === undefined)
? "—" : Number(r.coefficient).toFixed(2) + "×"; ? "—" : Number(r.coefficient).toFixed(2) + "×";
@@ -833,6 +967,7 @@ let accountApiKeys = [];
let accountUsageRecords = []; let accountUsageRecords = [];
let lastStats = null; let lastStats = null;
let lastRouting = null; let lastRouting = null;
let lastNetworkMap = null;
let availableModels = []; let availableModels = [];
let chatHistory = []; let chatHistory = [];
let chatBusy = false; let chatBusy = false;
@@ -932,7 +1067,7 @@ function createNewChatSession() {
clearChatPrompt(); clearChatPrompt();
saveChatSessionsStore(); saveChatSessionsStore();
renderChatSessionList(); renderChatSessionList();
renderChatHistory(); renderChatHistory(true);
renderChatAuthHint(); renderChatAuthHint();
const promptEl = $("chat-prompt"); const promptEl = $("chat-prompt");
if (promptEl) promptEl.focus(); if (promptEl) promptEl.focus();
@@ -954,7 +1089,7 @@ function selectChatSession(sessionId) {
} }
localStorage.setItem(CHAT_ACTIVE_SESSION_KEY, activeChatSessionId); localStorage.setItem(CHAT_ACTIVE_SESSION_KEY, activeChatSessionId);
renderChatSessionList(); renderChatSessionList();
renderChatHistory(); renderChatHistory(true);
renderChatAuthHint(); renderChatAuthHint();
} }
@@ -980,7 +1115,7 @@ function deleteChatSession(sessionId) {
} }
saveChatSessionsStore(); saveChatSessionsStore();
renderChatSessionList(); renderChatSessionList();
renderChatHistory(); renderChatHistory(true);
renderChatModels(); renderChatModels();
} }
@@ -1002,31 +1137,54 @@ function initChatSessions() {
if (active.model) selectedChatModel = active.model; if (active.model) selectedChatModel = active.model;
} }
renderChatSessionList(); renderChatSessionList();
renderChatHistory(); renderChatHistory(true);
} }
function renderChatSessionList() { function renderChatSessionList() {
const list = $("chat-session-list"); const list = $("chat-session-list");
if (!list) return; syncKeyedList(
if (!chatSessions.length) { list,
list.className = "chat-session-list empty-state"; chatSessions,
list.innerHTML = "No chats yet"; session => session.id,
return; session => `${session.id}|${session.updatedAt || ""}|${chatSessionTitle(session)}|${session.id === activeChatSessionId}`,
createSessionRowElement,
patchSessionRowElement,
);
}
function createSessionRowElement(session) {
const el = document.createElement("div");
el.className = "chat-session-item";
el.dataset.sessionId = session.id;
el.setAttribute("role", "button");
el.tabIndex = 0;
const title = document.createElement("div");
title.className = "chat-session-title";
const meta = document.createElement("div");
meta.className = "chat-session-meta";
const del = document.createElement("button");
del.type = "button";
del.className = "chat-session-delete";
del.title = "Delete chat";
del.dataset.deleteSession = session.id;
del.textContent = "×";
el.append(title, meta, del);
patchSessionRowElement(el, session);
return el;
}
function patchSessionRowElement(el, session) {
el.classList.toggle("active", session.id === activeChatSessionId);
el.querySelector(".chat-session-title").textContent = chatSessionTitle(session);
const when = formatSessionTime(session.updatedAt || session.createdAt);
const meta = el.querySelector(".chat-session-meta");
if (when) {
meta.textContent = when;
meta.hidden = false;
} else {
meta.textContent = "";
meta.hidden = true;
} }
list.className = "chat-session-list";
list.innerHTML = chatSessions.map(session => {
const active = session.id === activeChatSessionId ? " active" : "";
const title = esc(chatSessionTitle(session));
const when = esc(formatSessionTime(session.updatedAt || session.createdAt));
const sessionId = esc(session.id);
return `<div class="chat-session-item${active}" role="button" tabindex="0"` +
` data-session-id="${sessionId}">` +
`<div class="chat-session-title">${title}</div>` +
(when ? `<div class="chat-session-meta">${when}</div>` : "") +
`<button type="button" class="chat-session-delete" title="Delete chat"` +
` data-delete-session="${sessionId}">×</button>` +
`</div>`;
}).join("");
} }
function bindChatSessionList() { function bindChatSessionList() {
@@ -1069,6 +1227,7 @@ function switchDashboardTab(name) {
const promptEl = $("chat-prompt"); const promptEl = $("chat-prompt");
if (promptEl) promptEl.focus(); if (promptEl) promptEl.focus();
} }
refreshActiveTab(true);
} }
function updateSectionVisibility() { function updateSectionVisibility() {
@@ -1084,22 +1243,33 @@ function renderChatStatus(text) {
$("chat-status").textContent = text; $("chat-status").textContent = text;
} }
function renderChatHistory() { function renderChatHistory(force) {
const history = $("chat-history"); const history = $("chat-history");
if (!history) return; if (!history) return;
if (!force && chatHistory.some(msg => msg.streaming)) return;
if (!chatHistory.length) { if (!chatHistory.length) {
if (history.dataset.historySig === "empty") return;
history.dataset.historySig = "empty";
history.className = "chat-messages empty"; history.className = "chat-messages empty";
history.innerHTML = '<div class="chat-messages-inner">Send a message to start this conversation.</div>'; history.innerHTML = '<div class="chat-messages-inner">Send a message to start this conversation.</div>';
return; return;
} }
const sig = stableSig(chatHistory.map(msg => ({
role: msg.role,
content: msg.content,
streaming: Boolean(msg.streaming),
})));
if (!force && history.dataset.historySig === sig) return;
history.dataset.historySig = sig;
history.className = "chat-messages"; history.className = "chat-messages";
const nearBottom = history.scrollHeight - history.scrollTop - history.clientHeight < 80;
const rows = chatHistory.map(msg => { const rows = chatHistory.map(msg => {
const role = msg.role === "user" ? "user" : msg.role === "assistant" ? "assistant" : "error"; const role = msg.role === "user" ? "user" : msg.role === "assistant" ? "assistant" : "error";
const streaming = msg.streaming ? " streaming" : ""; const streaming = msg.streaming ? " streaming" : "";
return `<div class="chat-row ${role}"><div class="chat-bubble ${role}${streaming}">${esc(msg.content)}</div></div>`; return `<div class="chat-row ${role}"><div class="chat-bubble ${role}${streaming}">${esc(msg.content)}</div></div>`;
}).join(""); }).join("");
history.innerHTML = `<div class="chat-messages-inner">${rows}</div>`; history.innerHTML = `<div class="chat-messages-inner">${rows}</div>`;
history.scrollTop = history.scrollHeight; if (nearBottom || force) history.scrollTop = history.scrollHeight;
} }
function findRoutingForModel(model, routing) { function findRoutingForModel(model, routing) {
@@ -1140,9 +1310,17 @@ function chatModelOptionLabel(model, routing) {
return `${base} · ${health} · ${speedText}${suffix}`; return `${base} · ${health} · ${speedText}${suffix}`;
} }
function renderChatModels() { function renderChatModels(force) {
const select = $("chat-model"); const select = $("chat-model");
if (!select) return; if (!select) return;
const sig = stableSig(chatModelsSignature());
if (!force && panelSig.chatModels === sig) return;
if (!force && isEditing(select)) {
pendingChatModelRefresh = true;
return;
}
pendingChatModelRefresh = false;
panelSig.chatModels = sig;
const models = availableModels.slice(); const models = availableModels.slice();
if (!models.length) { if (!models.length) {
select.innerHTML = '<option value="">no models available</option>'; select.innerHTML = '<option value="">no models available</option>';
@@ -1161,6 +1339,15 @@ function renderChatModels() {
select.value = selectedChatModel; select.value = selectedChatModel;
} }
function bindChatModelSelect() {
const select = $("chat-model");
if (!select || select.dataset.bound) return;
select.dataset.bound = "1";
select.addEventListener("blur", () => {
if (pendingChatModelRefresh) renderChatModels(true);
});
}
function selectChatModel(value) { function selectChatModel(value) {
selectedChatModel = value || ""; selectedChatModel = value || "";
localStorage.setItem("meshnet_chat_model", selectedChatModel); localStorage.setItem("meshnet_chat_model", selectedChatModel);
@@ -1231,7 +1418,8 @@ function renderAuthForms(errorMsg) {
`<a class="${authTab === name ? "active" : ""}" onclick="switchAuthTab('${name}')">${label}</a>`; `<a class="${authTab === name ? "active" : ""}" onclick="switchAuthTab('${name}')">${label}</a>`;
const identityFields = const identityFields =
'<input id="auth-email" type="email" placeholder="email (or leave blank)">' + '<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" const form = authTab === "login"
? '<input id="auth-identifier" type="text" placeholder="email or wallet address">' + ? '<input id="auth-identifier" type="text" placeholder="email or wallet address">' +
'<input id="auth-password" type="password" placeholder="password">' + '<input id="auth-password" type="password" placeholder="password">' +
@@ -1253,12 +1441,14 @@ function switchAuthTab(name) { authTab = name; renderAuthForms(); }
async function doRegister() { async function doRegister() {
const email = $("auth-email").value.trim(); const email = $("auth-email").value.trim();
const wallet = $("auth-wallet").value.trim(); const wallet = $("auth-wallet").value.trim();
const nickname = $("auth-nickname").value.trim();
const password = $("auth-password").value; const password = $("auth-password").value;
const r = await apiCall("/v1/auth/register", "POST", 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; } if (!r.ok) { renderAuthForms(r.data.error || "registration failed"); return; }
setSession(r.data.session_token); setSession(r.data.session_token);
await renderAccountPanel(); await loadAccountSummary(true);
await refreshActiveTab(true);
} }
async function doLogin() { async function doLogin() {
@@ -1267,7 +1457,8 @@ async function doLogin() {
const r = await apiCall("/v1/auth/login", "POST", { identifier, password }); const r = await apiCall("/v1/auth/login", "POST", { identifier, password });
if (!r.ok) { renderAuthForms(r.data.error || "login failed"); return; } if (!r.ok) { renderAuthForms(r.data.error || "login failed"); return; }
setSession(r.data.session_token); setSession(r.data.session_token);
await renderAccountPanel(); await loadAccountSummary(true);
await refreshActiveTab(true);
} }
async function doLogout() { async function doLogout() {
@@ -1278,19 +1469,19 @@ async function doLogout() {
async function createKey() { async function createKey() {
const r = await apiCall("/v1/account/keys", "POST", {}); const r = await apiCall("/v1/account/keys", "POST", {});
if (r.ok) await renderAccountPanel(); if (r.ok) await loadAccountSummary(true);
} }
async function revokeKey(key) { async function revokeKey(key) {
if (!confirm("Revoke this API key? Clients using it will stop working.")) return; if (!confirm("Revoke this API key? Clients using it will stop working.")) return;
await apiCall("/v1/account/keys/revoke", "POST", { api_key: key }); await apiCall("/v1/account/keys/revoke", "POST", { api_key: key });
await renderAccountPanel(); await loadAccountSummary(true);
} }
async function topupKey(key) { async function topupKey(key) {
const r = await apiCall("/v1/account/topup", "POST", { api_key: key }); const r = await apiCall("/v1/account/topup", "POST", { api_key: key });
if (!r.ok) alert(r.data.error || "top-up failed"); if (!r.ok) alert(r.data.error || "top-up failed");
await renderAccountPanel(); await loadAccountSummary(true);
} }
const COPY_TOOLTIP_MS = 2000; const COPY_TOOLTIP_MS = 2000;
@@ -1354,54 +1545,112 @@ function renderChatAuthHint() {
} }
} }
async function renderAccountPanel() { let accountTopupAmount = 0;
function buildAccountPanelShell() {
$("account").innerHTML =
`<div id="account-panel-root">` +
`<div><b data-bind="who"></b> <span class="pill" data-bind="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" placeholder="display name" style="min-width:180px">` +
`<button class="small" type="button" onclick="saveNickname()">save</button></div>` +
`<div style="margin:6px 0">balance: <b data-bind="balance"></b> USDT · requests: <b data-bind="requests"></b> · ` +
`tokens: <b data-bind="tokens"></b> · spent: <b data-bind="spent"></b> USDT</div>` +
`<div style="margin:6px 0"><b class="dim">API keys</b> ` +
`<button class="small" onclick="createKey()">+ new key</button></div>` +
`<div id="account-keys"></div></div>`;
}
function renderAccountKeys(api_keys, balances, topup_amount) {
const el = $("account-keys");
if (!el) return;
if (!api_keys.length) {
el.innerHTML = '<div class="empty">no active keys</div>';
return;
}
let html = "";
for (const key of api_keys) {
html += `<div class="keybox">` +
`<span class="key-text" data-key="${esc(key)}" onclick="selectApiKeyText(this)" ondblclick="copyApiKeyFromTextEl(this)">${esc(key)}</span>` +
`<span class="dim">(${usdt(balances[key] ?? 0)} USDT)</span>` +
`<button class="small" type="button" onclick="copyApiKeyFromButton(this)">copy</button>` +
(topup_amount > 0
? ` <button class="small" onclick="topupKey('${esc(key)}')">+${usdt(topup_amount)} (devnet)</button>`
: "") +
` <button class="small" onclick="revokeKey('${esc(key)}')">revoke</button></div>`;
}
el.innerHTML = html;
}
function patchAccountPanelView(account, api_keys, balances, total_balance, usage, topup_amount) {
const root = $("account-panel-root");
if (!root) return false;
accountTopupAmount = topup_amount || 0;
root.querySelector('[data-bind="who"]').textContent = accountDisplayName(account);
root.querySelector('[data-bind="role"]').textContent = account.role;
const bal = root.querySelector('[data-bind="balance"]');
bal.textContent = usdt(total_balance);
bal.className = total_balance > 0 ? "ok" : "bad";
root.querySelector('[data-bind="requests"]').textContent = String(usage.requests);
root.querySelector('[data-bind="tokens"]').textContent = String(usage.total_tokens);
root.querySelector('[data-bind="spent"]').textContent = usdt(usage.total_cost);
const nick = $("account-nickname");
if (nick && !isEditing(nick)) nick.value = account.nickname || "";
renderIfChanged("account-keys", { api_keys, balances, topup_amount }, data => {
renderAccountKeys(data.api_keys, data.balances, data.topup_amount);
});
return true;
}
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 loadAccountSummary(true);
}
function applyAccountSummary(data, force) {
const { account, api_keys, balances, total_balance, usage, topup_amount } = data;
accountApiKeys = Array.isArray(api_keys) ? api_keys.slice() : [];
if (force || !patchAccountPanelView(account, api_keys, balances, total_balance, usage, topup_amount)) {
buildAccountPanelShell();
patchAccountPanelView(account, api_keys, balances, total_balance, usage, topup_amount);
}
renderChatAuthHint();
setLoggedInMode(true);
setAdminMode(account.role === "admin");
}
async function loadAccountSummary(force) {
const r = await apiCall("/v1/account"); const r = await apiCall("/v1/account");
if (r.status === 404) { // accounts disabled on this tracker if (r.status === 404) {
$("account-section").style.display = "none"; $("account-section").style.display = "none";
accountApiKeys = []; accountApiKeys = [];
accountUsageRecords = []; accountUsageRecords = [];
renderChatAuthHint(); renderChatAuthHint();
setLoggedInMode(false); setLoggedInMode(false);
setAdminMode(false); setAdminMode(false);
return; return false;
} }
if (!r.ok) { setSession(null); renderAuthForms(); return; } if (!r.ok) { setSession(null); renderAuthForms(); return false; }
const { account, api_keys, balances, total_balance, usage, topup_amount } = r.data; applyAccountSummary(r.data, force);
accountApiKeys = Array.isArray(api_keys) ? api_keys.slice() : []; if (dashboardTab === "chat" && !refreshBlocked()) renderChatModels(force);
accountUsageRecords = (usage && (usage.records || usage.recent)) || []; return true;
const who = account.email || account.wallet || account.account_id; }
let html =
`<div><b>${esc(who)}</b> <span class="pill">${esc(account.role)}</span> ` + async function loadAccountUsage() {
`<button class="small" style="float:right" onclick="doLogout()">log out</button></div>` + const r = await apiCall("/v1/account/usage");
`<div style="margin:6px 0">balance: <b class="${total_balance > 0 ? "ok" : "bad"}">` + if (!r.ok) return;
`${usdt(total_balance)}</b> USDT · requests: <b>${usage.requests}</b> · ` + accountUsageRecords = (r.data.records || r.data.recent) || [];
`tokens: <b>${usage.total_tokens}</b> · spent: <b>${usdt(usage.total_cost)}</b> USDT</div>`; renderIfChanged("usage-summary", accountUsageRecords, () => renderUsageSummary(accountUsageRecords));
html += '<div style="margin:6px 0"><b class="dim">API keys</b> ' + renderIfChanged("billing-usage", accountUsageRecords, () => renderBillingUsage(accountUsageRecords));
'<button class="small" onclick="createKey()">+ new key</button></div>'; }
if (api_keys.length) {
for (const key of api_keys) { /** @deprecated use loadAccountSummary */
html += `<div class="keybox">` + async function renderAccountPanel(force) {
`<span class="key-text" data-key="${esc(key)}" onclick="selectApiKeyText(this)" ondblclick="copyApiKeyFromTextEl(this)">${esc(key)}</span>` + return loadAccountSummary(force);
`<span class="dim">(${usdt(balances[key] ?? 0)} USDT)</span>` +
`<button class="small" type="button" onclick="copyApiKeyFromButton(this)">copy</button>` +
(topup_amount > 0
? ` <button class="small" onclick="topupKey('${esc(key)}')">+${usdt(topup_amount)} (devnet)</button>`
: "") +
` <button class="small" onclick="revokeKey('${esc(key)}')">revoke</button></div>`;
}
} else {
html += '<div class="empty">no active keys</div>';
}
$("account").innerHTML = html;
renderUsageSummary(accountUsageRecords);
renderNodeThroughput(lastStats);
renderBillingUsage(accountUsageRecords);
renderChatAuthHint();
renderChatModels();
renderChatHistory();
setLoggedInMode(true);
setAdminMode(account.role === "admin");
if (account.role === "admin") await renderAdminPanel();
} }
let chatAbortController = null; let chatAbortController = null;
@@ -1423,7 +1672,7 @@ function updateStreamingChatBubble(content) {
if (!history) return; if (!history) return;
const bubbles = history.querySelectorAll(".chat-bubble.assistant.streaming"); const bubbles = history.querySelectorAll(".chat-bubble.assistant.streaming");
const bubble = bubbles[bubbles.length - 1]; const bubble = bubbles[bubbles.length - 1];
if (!bubble) { renderChatHistory(); return; } if (!bubble) { renderChatHistory(true); return; }
bubble.textContent = content; bubble.textContent = content;
const nearBottom = history.scrollHeight - history.scrollTop - history.clientHeight < 80; const nearBottom = history.scrollHeight - history.scrollTop - history.clientHeight < 80;
if (nearBottom) history.scrollTop = history.scrollHeight; if (nearBottom) history.scrollTop = history.scrollHeight;
@@ -1433,7 +1682,7 @@ function finalizeAssistantMessage(msg) {
delete msg.streaming; delete msg.streaming;
if (!chatHistory.includes(msg)) chatHistory.push(msg); if (!chatHistory.includes(msg)) chatHistory.push(msg);
if (!msg.content) msg.content = "(empty response)"; if (!msg.content) msg.content = "(empty response)";
renderChatHistory(); renderChatHistory(true);
persistActiveChatSession(); persistActiveChatSession();
} }
@@ -1441,7 +1690,7 @@ function removeAssistantMessage(msg) {
const index = chatHistory.indexOf(msg); const index = chatHistory.indexOf(msg);
if (index >= 0) { if (index >= 0) {
chatHistory.splice(index, 1); chatHistory.splice(index, 1);
renderChatHistory(); renderChatHistory(true);
} }
} }
@@ -1472,7 +1721,7 @@ async function sendChat() {
chatHistory.push({ role: "user", content: prompt, model: selectedChatModel }); chatHistory.push({ role: "user", content: prompt, model: selectedChatModel });
const assistantMessage = { role: "assistant", content: "", model: selectedChatModel, streaming: true }; const assistantMessage = { role: "assistant", content: "", model: selectedChatModel, streaming: true };
chatHistory.push(assistantMessage); chatHistory.push(assistantMessage);
renderChatHistory(); renderChatHistory(true);
persistActiveChatSession(); persistActiveChatSession();
renderChatStatus("sending request…"); renderChatStatus("sending request…");
let tokens = 0; let tokens = 0;
@@ -1555,7 +1804,7 @@ async function sendChat() {
removeAssistantMessage(assistantMessage); removeAssistantMessage(assistantMessage);
const error = (err && err.message) || "request failed"; const error = (err && err.message) || "request failed";
chatHistory.push({ role: "error", content: error, model: selectedChatModel }); chatHistory.push({ role: "error", content: error, model: selectedChatModel });
renderChatHistory(); renderChatHistory(true);
persistActiveChatSession(); persistActiveChatSession();
renderChatStatus(error); renderChatStatus(error);
} }
@@ -1583,29 +1832,13 @@ function bindChatPromptShortcuts() {
}); });
} }
async function renderAdminPanel() {
const r = await apiCall("/v1/admin/accounts");
if (!r.ok) { setAdminMode(false); return; }
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)),
`<span class="pill">${esc(a.role)}</span>`,
String((a.api_keys || []).length),
`<span class="num ${balance > 0 ? "ok" : "bad"}">${usdt(balance)}</span>`,
new Date(a.created_ts * 1000).toLocaleDateString(),
];
});
$("admin").innerHTML = table(["account", "role", "keys", "balance (USDT)", "created"], rows);
}
async function cancelProxyRequest(requestId) { async function cancelProxyRequest(requestId) {
const r = await apiCall( const r = await apiCall(
`/v1/proxy/requests/${encodeURIComponent(requestId)}/cancel`, `/v1/proxy/requests/${encodeURIComponent(requestId)}/cancel`,
"POST", "POST",
{}, {},
); );
if (r.ok) refresh(); if (r.ok) await refreshCallWall();
} }
$("call-wall").addEventListener("click", (event) => { $("call-wall").addEventListener("click", (event) => {
@@ -1616,74 +1849,166 @@ $("call-wall").addEventListener("click", (event) => {
if (requestId) cancelProxyRequest(requestId); if (requestId) cancelProxyRequest(requestId);
}); });
async function refresh() { function applyAvailableModels(models, map) {
$("self-url").textContent = location.host; lastNetworkMap = map || lastNetworkMap;
const [raft, map, stats, models, consoleData, routing, adminData] = await Promise.all([ availableModels = ((models && models.data) || []).map(model => {
fetchJson("/v1/raft/status"), const entry = {
fetchJson("/v1/network/map"), id: model.id,
fetchJson("/v1/stats"), name: model.name || model.id,
fetchJson("/v1/models"), hf_repo: model.hf_repo,
fetchJson("/v1/console"), recommended: Boolean(model.recommended),
fetchJson("/v1/routing"), aliases: model.aliases || [],
isAdmin ? Promise.all([ coverage: model.shard_coverage_percentage,
fetchJson("/v1/billing/summary"), servedCopies: model.served_model_copies,
fetchJson("/v1/billing/settlements"), };
fetchJson("/v1/registry/wallets"), entry.servedCopies = modelServedCopiesFromMap(lastNetworkMap, entry);
]) : Promise.resolve([null, null, null]), return entry;
]); }).filter(model => model.id);
const [summary, settlements, wallets] = adminData; }
lastStats = stats;
lastRouting = routing; function markRefreshed() {
availableModels = ((models && models.data) || []).map(model => ({
id: model.id,
name: model.name || model.id,
hf_repo: model.hf_repo,
recommended: Boolean(model.recommended),
aliases: model.aliases || [],
coverage: model.shard_coverage_percentage,
servedCopies: model.served_model_copies,
})).filter(model => model.id);
renderHive(raft);
renderNodes(map);
renderBilling(summary);
renderSettlements(settlements);
renderFraud(wallets, summary);
renderStats(stats);
renderRouting(routing);
renderCallWall(consoleData, stats, map);
renderConsole(consoleData);
renderNodeThroughput(stats);
renderChatModels();
renderChatHistory();
$("refreshed").textContent = "refreshed " + new Date().toLocaleTimeString(); $("refreshed").textContent = "refreshed " + new Date().toLocaleTimeString();
} }
const REFRESH_MS = 10000; async function fetchOverviewTab() {
const fetches = [
fetchJson("/v1/raft/status"),
fetchJson("/v1/network/map"),
fetchJson("/v1/stats"),
fetchJson("/v1/routing"),
fetchJson("/v1/console"),
];
if (isLoggedIn) fetches.push(apiCall("/v1/account"));
const results = await Promise.all(fetches);
const [raft, map, stats, routing, consoleData, accountResp] = results;
lastStats = stats;
lastRouting = routing;
lastNetworkMap = map;
if (accountResp && accountResp.ok) applyAccountSummary(accountResp.data, false);
renderIfChanged("hive", raft, renderHive);
renderIfChanged("nodes", map, renderNodes);
renderIfChanged("stats", stats, renderStats);
renderIfChanged("routing", routing, renderRouting);
renderIfChanged("call-wall", { consoleData, stats, map }, data => renderCallWall(data.consoleData, data.stats, data.map));
}
async function fetchChatTab() {
const fetches = [
fetchJson("/v1/network/map"),
fetchJson("/v1/models"),
fetchJson("/v1/routing"),
];
if (isLoggedIn) fetches.push(apiCall("/v1/account"));
const [map, models, routing, accountResp] = await Promise.all(fetches);
lastRouting = routing;
applyAvailableModels(models, map);
if (accountResp && accountResp.ok) applyAccountSummary(accountResp.data, false);
if (!refreshBlocked()) {
renderChatModels(true);
} else {
pendingChatModelRefresh = true;
}
}
async function fetchBillingTab() {
const [stats, , accountResp, adminData] = await Promise.all([
fetchJson("/v1/stats"),
loadAccountUsage(),
isLoggedIn ? apiCall("/v1/account") : Promise.resolve(null),
isAdmin ? Promise.all([
fetchJson("/v1/billing/summary"),
fetchJson("/v1/billing/settlements"),
]) : Promise.resolve(null),
]);
lastStats = stats;
if (accountResp && accountResp.ok) applyAccountSummary(accountResp.data, false);
renderIfChanged("node-throughput", stats, renderNodeThroughput);
if (adminData) {
const [summary, settlements] = adminData;
renderIfChanged("billing-summary", summary, renderBilling);
renderIfChanged("settlements", settlements, renderSettlements);
}
}
async function fetchAdminTab() {
const fetches = [
fetchJson("/v1/console"),
fetchJson("/v1/billing/summary"),
fetchJson("/v1/registry/wallets"),
];
if (isAdmin) fetches.push(apiCall("/v1/admin/accounts"));
const results = await Promise.all(fetches);
const [consoleData, summary, wallets, adminResp] = results;
renderIfChanged("console", consoleData, renderConsole);
renderIfChanged("billing-summary", summary, data => renderBilling(data));
renderIfChanged("fraud", { wallets, summary }, data => renderFraud(data.wallets, data.summary));
if (adminResp && adminResp.ok) {
renderIfChanged("admin", adminResp.data.accounts || [], accounts => {
const rows = accounts.map(a => {
const balance = Object.values(a.balances || {}).reduce((x, y) => x + y, 0);
return [
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>`,
new Date(a.created_ts * 1000).toLocaleDateString(),
];
});
$("admin").innerHTML = table(["account", "role", "keys", "balance (USDT)", "created"], rows);
});
}
}
async function refreshCallWall() {
const [consoleData, map] = await Promise.all([
fetchJson("/v1/console"),
fetchJson("/v1/network/map"),
]);
if (map) lastNetworkMap = map;
renderIfChanged("call-wall", { consoleData, stats: lastStats, map }, data =>
renderCallWall(data.consoleData, data.stats, data.map));
}
const TAB_FETCHERS = {
overview: fetchOverviewTab,
chat: fetchChatTab,
billing: fetchBillingTab,
admin: fetchAdminTab,
};
async function refreshActiveTab(force) {
$("self-url").textContent = location.host;
if (refreshBlocked() && !force) return;
const fetcher = TAB_FETCHERS[dashboardTab];
if (fetcher) await fetcher();
markRefreshed();
}
const CALL_WALL_POLL_MS = 5000;
async function pollCallWallIfIdle() {
if (dashboardTab !== "overview" || refreshBlocked()) return;
await refreshCallWall();
}
function selectionActive() { function selectionActive() {
const sel = window.getSelection(); const sel = window.getSelection();
return sel && !sel.isCollapsed && sel.toString().length > 0; return sel && !sel.isCollapsed && sel.toString().length > 0;
} }
async function refreshIfIdle() {
if (selectionActive()) return;
await refresh();
}
refresh();
bindChatSessionList(); bindChatSessionList();
bindChatModelSelect();
initChatSessions(); initChatSessions();
bindChatPromptShortcuts(); bindChatPromptShortcuts();
renderAccountPanel(); (async () => {
renderChatModels(); if (sessionToken) await loadAccountSummary(true);
renderChatHistory(); else renderAuthForms();
renderChatAuthHint(); await refreshActiveTab(true);
setInterval(refreshIfIdle, REFRESH_MS); renderChatHistory(true);
setInterval(() => { renderChatAuthHint();
if (!sessionToken && !isLoggedIn) return; if (dashboardTab === "chat") renderChatModels(true);
if (selectionActive()) return; })();
renderAccountPanel(); setInterval(pollCallWallIfIdle, CALL_WALL_POLL_MS);
}, REFRESH_MS);
</script> </script>
</body> </body>
</html> </html>

View File

@@ -39,6 +39,38 @@
] ]
} }
}, },
"qwen2.5-0.5b-instruct": {
"layers_start": 0,
"layers_end": 23,
"hf_repo": "Qwen/Qwen2.5-0.5B-Instruct",
"aliases": [
"qwen2.5-0.5b",
"Qwen2.5-0.5B-Instruct",
"Qwen/Qwen2.5-0.5B-Instruct"
],
"deployment_status": "supported",
"price_per_1k_tokens": 0.002,
"input_price_per_1k_tokens": 0.002,
"output_price_per_1k_tokens": 0.002,
"hf_aliases": [],
"hf_verified_match_note": "Static 10× dev-funding markup over ~$0.20/1M commercial API reference (Qwen-class hosted rates). $0.002/1k = $2/1M blended input+output.",
"required_model_bytes": 1056964608,
"download_size_bytes": 1056964608,
"native_quantization": "bfloat16",
"canonical_audit_dtype": "bfloat16",
"canonical_audit_quantization": "bfloat16",
"bytes_per_layer": {
"bfloat16": 44040192
},
"metadata": {
"architecture": "Dense transformer (GQA)",
"total_parameters": "0.5B",
"num_layers": 24,
"context_length": 32768,
"native_quantization": "bfloat16",
"download_size_gb": 1
}
},
"qwen3.6-35b-a3b": { "qwen3.6-35b-a3b": {
"layers_start": 0, "layers_start": 0,
"layers_end": 39, "layers_end": 39,

View File

@@ -570,7 +570,7 @@ class _NodeEntry:
"benchmark_tokens_per_sec", "quantization", "managed_assignment", "benchmark_tokens_per_sec", "quantization", "managed_assignment",
"model_tokens_per_sec", "model_tokens_per_sec",
"pending_directives", "last_heartbeat", "tracker_mode", "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) # heartbeat stats (reported by node, cumulative)
"total_requests", "failed_requests", "queue_depth", "proxy_inflight", "uptime_seconds", "total_requests", "failed_requests", "queue_depth", "proxy_inflight", "uptime_seconds",
"current_requests", "current_requests",
@@ -606,6 +606,7 @@ class _NodeEntry:
relay_addr: str | None = None, relay_addr: str | None = None,
cert_fingerprint: str | None = None, cert_fingerprint: str | None = None,
peer_id: str | None = None, peer_id: str | None = None,
friendly_name: str | None = None,
) -> None: ) -> None:
self.node_id = node_id self.node_id = node_id
self.endpoint = endpoint self.endpoint = endpoint
@@ -632,6 +633,7 @@ class _NodeEntry:
self.relay_addr = relay_addr self.relay_addr = relay_addr
self.cert_fingerprint = cert_fingerprint self.cert_fingerprint = cert_fingerprint
self.peer_id = peer_id self.peer_id = peer_id
self.friendly_name = friendly_name
self.pending_directives: list[dict] = [] self.pending_directives: list[dict] = []
self.last_heartbeat: float = time.monotonic() self.last_heartbeat: float = time.monotonic()
self.total_requests: int = 0 self.total_requests: int = 0
@@ -2231,6 +2233,22 @@ def _registration_ban_error(contracts: Any | None, wallet_address: str | None) -
return 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( def _tracker_log(
server: "_TrackerHTTPServer", server: "_TrackerHTTPServer",
level: str, level: str,
@@ -2606,6 +2624,9 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
if self.path == "/v1/auth/logout": if self.path == "/v1/auth/logout":
self._handle_auth_logout() self._handle_auth_logout()
return return
if self.path == "/v1/account/profile":
self._handle_account_profile()
return
if self.path == "/v1/account/keys": if self.path == "/v1/account/keys":
self._handle_account_key_create() self._handle_account_key_create()
return return
@@ -2686,6 +2707,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
self._handle_billing_summary() self._handle_billing_summary()
elif parsed.path == "/v1/billing/settlements": elif parsed.path == "/v1/billing/settlements":
self._handle_billing_settlements() self._handle_billing_settlements()
elif parsed.path == "/v1/account/usage":
self._handle_account_usage(parsed)
elif parsed.path == "/v1/account": elif parsed.path == "/v1/account":
self._handle_account_me() self._handle_account_me()
elif parsed.path == "/v1/admin/accounts": elif parsed.path == "/v1/admin/accounts":
@@ -2923,6 +2946,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
"nodes": [ "nodes": [
{ {
"node_id": node.node_id, "node_id": node.node_id,
"friendly_name": node.friendly_name,
"endpoint": node.endpoint, "endpoint": node.endpoint,
"relay_addr": node.relay_addr, "relay_addr": node.relay_addr,
"peer_id": node.peer_id, "peer_id": node.peer_id,
@@ -4001,6 +4025,11 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
relay_addr = body.get("relay_addr") or None relay_addr = body.get("relay_addr") or None
cert_fingerprint = body.get("cert_fingerprint") or None cert_fingerprint = body.get("cert_fingerprint") or None
peer_id = body.get("peer_id") 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( node_id = _node_id_for_registration(
endpoint, endpoint,
@@ -4035,6 +4064,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
relay_addr=relay_addr, relay_addr=relay_addr,
cert_fingerprint=cert_fingerprint, cert_fingerprint=cert_fingerprint,
peer_id=peer_id, peer_id=peer_id,
friendly_name=friendly_name,
) )
with server.lock: with server.lock:
self._purge_expired_nodes() self._purge_expired_nodes()
@@ -4053,6 +4083,12 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
for eid in stale_ids: for eid in stale_ids:
old = server.registry.pop(eid) old = server.registry.pop(eid)
stale_entries.append((eid, old)) 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( is_topology_change = node_id not in stale_ids or any(
(old.shard_start, old.shard_end) != (entry.shard_start, entry.shard_end) (old.shard_start, old.shard_end) != (entry.shard_start, entry.shard_end)
for eid, old in stale_entries for eid, old in stale_entries
@@ -4162,6 +4198,12 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
entry.uptime_seconds = float(body["uptime_seconds"]) entry.uptime_seconds = float(body["uptime_seconds"])
if "status" in body and body["status"] in ("ready", "loading"): if "status" in body and body["status"] in ("ready", "loading"):
entry.status = body["status"] 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) directives = list(entry.pending_directives)
entry.pending_directives.clear() entry.pending_directives.clear()
new_assignment = entry.pending_new_assignment new_assignment = entry.pending_new_assignment
@@ -4443,6 +4485,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
email=body.get("email"), email=body.get("email"),
wallet=body.get("wallet"), wallet=body.get("wallet"),
password=str(body.get("password") or ""), password=str(body.get("password") or ""),
nickname=body.get("nickname"),
) )
except ValueError as exc: except ValueError as exc:
self._send_json(400, {"error": str(exc)}) self._send_json(400, {"error": str(exc)})
@@ -4495,7 +4538,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
self._send_json(200, {"ok": True}, headers={"Set-Cookie": _session_cookie_header(None)}) self._send_json(200, {"ok": True}, headers={"Set-Cookie": _session_cookie_header(None)})
def _handle_account_me(self): def _handle_account_me(self):
"""Balance, usage, and API keys for the logged-in account.""" """Balance, usage totals, and API keys for the logged-in account."""
server: _TrackerHTTPServer = self.server # type: ignore[assignment] server: _TrackerHTTPServer = self.server # type: ignore[assignment]
if self._require_accounts() is None: if self._require_accounts() is None:
return return
@@ -4505,10 +4548,10 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
return return
keys = server.accounts.keys_for(account["account_id"]) # type: ignore[union-attr] keys = server.accounts.keys_for(account["account_id"]) # type: ignore[union-attr]
balances = {} balances = {}
usage: dict = {"requests": 0, "total_tokens": 0, "total_cost": 0.0, "recent": [], "records": []} usage: dict = {"requests": 0, "total_tokens": 0, "total_cost": 0.0}
if server.billing is not None: if server.billing is not None:
balances = {key: server.billing.get_client_balance(key) for key in keys} balances = {key: server.billing.get_client_balance(key) for key in keys}
usage = server.billing.usage_for(keys) usage = server.billing.usage_totals_for(keys)
self._send_json(200, { self._send_json(200, {
"account": account, "account": account,
"api_keys": keys, "api_keys": keys,
@@ -4518,6 +4561,56 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
"topup_amount": server.devnet_topup_amount if server.billing is not None else 0.0, "topup_amount": server.devnet_topup_amount if server.billing is not None else 0.0,
}) })
def _handle_account_usage(self, parsed: urllib.parse.ParseResult):
"""Per-request charge history for the logged-in account (billing tab)."""
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
if self._require_accounts() is None:
return
account = self._session_account()
if account is None:
self._send_json(401, {"error": "login required"})
return
keys = server.accounts.keys_for(account["account_id"]) # type: ignore[union-attr]
if server.billing is None:
self._send_json(200, {
"requests": 0,
"total_tokens": 0,
"total_cost": 0.0,
"records": [],
"recent": [],
})
return
limit = None
raw_limit = urllib.parse.parse_qs(parsed.query).get("limit", [None])[0]
if raw_limit is not None:
try:
limit = max(1, int(raw_limit))
except ValueError:
self._send_json(400, {"error": "limit must be an integer"})
return
self._send_json(200, server.billing.usage_for(keys, recent_limit=limit))
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): def _handle_account_key_create(self):
accounts = self._require_accounts() accounts = self._require_accounts()
if accounts is None: if accounts is None:
@@ -6076,6 +6169,10 @@ class TrackerServer:
shard_end = int(payload["shard_end"]) if payload.get("shard_end") is not None else None shard_end = int(payload["shard_end"]) if payload.get("shard_end") is not None else None
except (TypeError, ValueError): except (TypeError, ValueError):
return return
try:
friendly_name = _normalize_friendly_name(payload.get("friendly_name"))
except ValueError:
friendly_name = None
entry = _NodeEntry( entry = _NodeEntry(
node_id=node_id, node_id=node_id,
endpoint=endpoint.rstrip("/"), endpoint=endpoint.rstrip("/"),
@@ -6095,6 +6192,7 @@ class TrackerServer:
if isinstance(payload.get("downloaded_models"), list) if isinstance(payload.get("downloaded_models"), list)
else None else None
), ),
friendly_name=_normalize_friendly_name(payload.get("friendly_name")),
) )
with self._lock: with self._lock:
self._registry[node_id] = entry 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") 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(): def test_login_by_email_or_wallet():
store = AccountStore() store = AccountStore()
account = store.register( account = store.register(
@@ -166,6 +196,41 @@ def test_register_login_and_account_view(account_tracker):
assert me["api_keys"] == [reg["api_key"]] assert me["api_keys"] == [reg["api_key"]]
assert me["total_balance"] == pytest.approx(0.0) assert me["total_balance"] == pytest.approx(0.0)
assert me["usage"]["requests"] == 0 assert me["usage"]["requests"] == 0
assert "records" not in me["usage"]
assert "recent" not in me["usage"]
def test_account_usage_endpoint_returns_records(account_tracker):
url, ledger = account_tracker
reg = _call(f"{url}/v1/auth/register", "POST",
{"email": "usage@example.com", "password": "secret-123"})
ledger.charge_request(reg["api_key"], "test-model", total_tokens=42, node_work=[("wallet-1", 1)])
usage = _call(f"{url}/v1/account/usage", token=reg["session_token"])
assert usage["requests"] == 1
assert usage["total_tokens"] == 42
assert len(usage["records"]) == 1
assert usage["records"][0]["model"] == "test-model"
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): def test_login_sets_cookie_and_cookie_auth_survives_tracker_restart(tmp_path):

View File

@@ -52,6 +52,34 @@ def test_dashboard_chat_uses_streaming_fetch():
assert 'console.error("chat stream failed", err)' in html 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(): def test_dashboard_chat_model_selector_shows_health_and_speed():
tracker = TrackerServer() tracker = TrackerServer()
port = tracker.start() port = tracker.start()
@@ -63,11 +91,15 @@ def test_dashboard_chat_model_selector_shows_health_and_speed():
tracker.stop() tracker.stop()
assert "chatModelHealthHp" in html 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 "chatModelTypicalTps" in html
assert "chatModelOptionLabel" in html assert "chatModelOptionLabel" in html
assert "findRoutingForModel" in html assert "findRoutingForModel" in html
assert "tok/s" in html assert "tok/s" in html
assert "0.0HP" in html or "toFixed(1)}HP" in html assert "toFixed(2)}HP" in html or '${copies(v)}HP' in html
def test_dashboard_chat_sessions_use_delegated_handlers(): def test_dashboard_chat_sessions_use_delegated_handlers():
@@ -81,12 +113,42 @@ def test_dashboard_chat_sessions_use_delegated_handlers():
tracker.stop() tracker.stop()
assert "bindChatSessionList" in html assert "bindChatSessionList" in html
assert "data-session-id=" in html assert "dataset.sessionId" in html
assert "data-delete-session=" in html assert "dataset.deleteSession" in html
assert '[data-session-id]' in html
assert 'onclick="selectChatSession(' not in html assert 'onclick="selectChatSession(' not in html
assert 'onclick="deleteChatSession(' not in html assert 'onclick="deleteChatSession(' not in html
def test_dashboard_incremental_refresh_helpers():
tracker = TrackerServer()
port = tracker.start()
try:
html = urllib.request.urlopen(
f"http://127.0.0.1:{port}/dashboard"
).read().decode()
finally:
tracker.stop()
assert "renderIfChanged" in html
assert "syncKeyedList" in html
assert "refreshBlocked" in html
assert "patchAccountPanelView" in html
assert "buildAccountPanelShell" in html
assert "refreshActiveTab" in html
assert "TAB_FETCHERS" in html
assert "loadAccountSummary" in html
assert "loadAccountUsage" in html
assert "fetchOverviewTab" in html
assert "pollCallWallIfIdle" in html
assert "pendingChatModelRefresh" in html
assert 'renderChatHistory(true)' in html
assert "renderChatHistory();" not in html
assert "refreshIfIdle" not in html
assert "refreshAccountIfIdle" not in html
assert "setInterval(refreshIfIdle" not in html
def test_dashboard_served_by_follower(): def test_dashboard_served_by_follower():
"""A tracker that is not the leader (unreachable peers → never elected) """A tracker that is not the leader (unreachable peers → never elected)
still serves the dashboard from its own replicated state.""" still serves the dashboard from its own replicated state."""

View File

@@ -186,3 +186,24 @@ def test_qwen_preset_prices_apply_to_all_aliases(tmp_path):
assert billing.price_for("some/other-model") == pytest.approx(0.02) assert billing.price_for("some/other-model") == pytest.approx(0.02)
finally: finally:
pass pass
def test_qwen25_preset_price_is_ten_x_commercial_reference(tmp_path):
"""Qwen2.5-0.5B bills at 10× ~$0.20/1M reference ($0.002/1k), not the 0.02 default."""
import pytest
from meshnet_tracker.server import TrackerServer, _resolve_model_preset, DEFAULT_MODEL_PRESETS
name, preset = _resolve_model_preset(DEFAULT_MODEL_PRESETS, "Qwen/Qwen2.5-0.5B-Instruct")
assert name == "qwen2.5-0.5b-instruct"
assert preset["price_per_1k_tokens"] == pytest.approx(0.002)
tracker = TrackerServer(billing_db=str(tmp_path / "billing.sqlite"))
billing = tracker._billing
assert billing is not None
for key in (
"qwen2.5-0.5b",
"Qwen2.5-0.5B-Instruct",
"Qwen/Qwen2.5-0.5B-Instruct",
):
assert billing.price_for(key) == pytest.approx(0.002), key
assert billing.price_for("unrelated-model") == pytest.approx(0.02)