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)),
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

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

View File

@@ -219,6 +219,7 @@
<h1>meshnet tracker</h1>
<span class="meta" id="self-url"></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>
<nav class="dashboard-tabs" aria-label="Dashboard sections">
<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 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 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; };
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();
@@ -317,6 +353,102 @@ function resolveModelGroup(node, aliasMap) {
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) {
try {
const headers = {};
@@ -371,7 +503,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)),
@@ -448,11 +580,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>`,
@@ -585,6 +718,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> · ` +
@@ -594,7 +728,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) + "×";
@@ -833,6 +967,7 @@ let accountApiKeys = [];
let accountUsageRecords = [];
let lastStats = null;
let lastRouting = null;
let lastNetworkMap = null;
let availableModels = [];
let chatHistory = [];
let chatBusy = false;
@@ -932,7 +1067,7 @@ function createNewChatSession() {
clearChatPrompt();
saveChatSessionsStore();
renderChatSessionList();
renderChatHistory();
renderChatHistory(true);
renderChatAuthHint();
const promptEl = $("chat-prompt");
if (promptEl) promptEl.focus();
@@ -954,7 +1089,7 @@ function selectChatSession(sessionId) {
}
localStorage.setItem(CHAT_ACTIVE_SESSION_KEY, activeChatSessionId);
renderChatSessionList();
renderChatHistory();
renderChatHistory(true);
renderChatAuthHint();
}
@@ -980,7 +1115,7 @@ function deleteChatSession(sessionId) {
}
saveChatSessionsStore();
renderChatSessionList();
renderChatHistory();
renderChatHistory(true);
renderChatModels();
}
@@ -1002,31 +1137,54 @@ function initChatSessions() {
if (active.model) selectedChatModel = active.model;
}
renderChatSessionList();
renderChatHistory();
renderChatHistory(true);
}
function renderChatSessionList() {
const list = $("chat-session-list");
if (!list) return;
if (!chatSessions.length) {
list.className = "chat-session-list empty-state";
list.innerHTML = "No chats yet";
return;
syncKeyedList(
list,
chatSessions,
session => session.id,
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() {
@@ -1069,6 +1227,7 @@ function switchDashboardTab(name) {
const promptEl = $("chat-prompt");
if (promptEl) promptEl.focus();
}
refreshActiveTab(true);
}
function updateSectionVisibility() {
@@ -1084,22 +1243,33 @@ function renderChatStatus(text) {
$("chat-status").textContent = text;
}
function renderChatHistory() {
function renderChatHistory(force) {
const history = $("chat-history");
if (!history) return;
if (!force && chatHistory.some(msg => msg.streaming)) return;
if (!chatHistory.length) {
if (history.dataset.historySig === "empty") return;
history.dataset.historySig = "empty";
history.className = "chat-messages empty";
history.innerHTML = '<div class="chat-messages-inner">Send a message to start this conversation.</div>';
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";
const nearBottom = history.scrollHeight - history.scrollTop - history.clientHeight < 80;
const rows = chatHistory.map(msg => {
const role = msg.role === "user" ? "user" : msg.role === "assistant" ? "assistant" : "error";
const streaming = msg.streaming ? " streaming" : "";
return `<div class="chat-row ${role}"><div class="chat-bubble ${role}${streaming}">${esc(msg.content)}</div></div>`;
}).join("");
history.innerHTML = `<div class="chat-messages-inner">${rows}</div>`;
history.scrollTop = history.scrollHeight;
if (nearBottom || force) history.scrollTop = history.scrollHeight;
}
function findRoutingForModel(model, routing) {
@@ -1140,9 +1310,17 @@ function chatModelOptionLabel(model, routing) {
return `${base} · ${health} · ${speedText}${suffix}`;
}
function renderChatModels() {
function renderChatModels(force) {
const select = $("chat-model");
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();
if (!models.length) {
select.innerHTML = '<option value="">no models available</option>';
@@ -1161,6 +1339,15 @@ function renderChatModels() {
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) {
selectedChatModel = value || "";
localStorage.setItem("meshnet_chat_model", selectedChatModel);
@@ -1231,7 +1418,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">' +
@@ -1253,12 +1441,14 @@ 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();
await loadAccountSummary(true);
await refreshActiveTab(true);
}
async function doLogin() {
@@ -1267,7 +1457,8 @@ async function doLogin() {
const r = await apiCall("/v1/auth/login", "POST", { identifier, password });
if (!r.ok) { renderAuthForms(r.data.error || "login failed"); return; }
setSession(r.data.session_token);
await renderAccountPanel();
await loadAccountSummary(true);
await refreshActiveTab(true);
}
async function doLogout() {
@@ -1278,19 +1469,19 @@ async function doLogout() {
async function createKey() {
const r = await apiCall("/v1/account/keys", "POST", {});
if (r.ok) await renderAccountPanel();
if (r.ok) await loadAccountSummary(true);
}
async function revokeKey(key) {
if (!confirm("Revoke this API key? Clients using it will stop working.")) return;
await apiCall("/v1/account/keys/revoke", "POST", { api_key: key });
await renderAccountPanel();
await loadAccountSummary(true);
}
async function topupKey(key) {
const r = await apiCall("/v1/account/topup", "POST", { api_key: key });
if (!r.ok) alert(r.data.error || "top-up failed");
await renderAccountPanel();
await loadAccountSummary(true);
}
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");
if (r.status === 404) { // accounts disabled on this tracker
if (r.status === 404) {
$("account-section").style.display = "none";
accountApiKeys = [];
accountUsageRecords = [];
renderChatAuthHint();
setLoggedInMode(false);
setAdminMode(false);
return;
return false;
}
if (!r.ok) { setSession(null); renderAuthForms(); return; }
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;
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">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>`;
html += '<div style="margin:6px 0"><b class="dim">API keys</b> ' +
'<button class="small" onclick="createKey()">+ new key</button></div>';
if (api_keys.length) {
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>`;
}
} 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();
if (!r.ok) { setSession(null); renderAuthForms(); return false; }
applyAccountSummary(r.data, force);
if (dashboardTab === "chat" && !refreshBlocked()) renderChatModels(force);
return true;
}
async function loadAccountUsage() {
const r = await apiCall("/v1/account/usage");
if (!r.ok) return;
accountUsageRecords = (r.data.records || r.data.recent) || [];
renderIfChanged("usage-summary", accountUsageRecords, () => renderUsageSummary(accountUsageRecords));
renderIfChanged("billing-usage", accountUsageRecords, () => renderBillingUsage(accountUsageRecords));
}
/** @deprecated use loadAccountSummary */
async function renderAccountPanel(force) {
return loadAccountSummary(force);
}
let chatAbortController = null;
@@ -1423,7 +1672,7 @@ function updateStreamingChatBubble(content) {
if (!history) return;
const bubbles = history.querySelectorAll(".chat-bubble.assistant.streaming");
const bubble = bubbles[bubbles.length - 1];
if (!bubble) { renderChatHistory(); return; }
if (!bubble) { renderChatHistory(true); return; }
bubble.textContent = content;
const nearBottom = history.scrollHeight - history.scrollTop - history.clientHeight < 80;
if (nearBottom) history.scrollTop = history.scrollHeight;
@@ -1433,7 +1682,7 @@ function finalizeAssistantMessage(msg) {
delete msg.streaming;
if (!chatHistory.includes(msg)) chatHistory.push(msg);
if (!msg.content) msg.content = "(empty response)";
renderChatHistory();
renderChatHistory(true);
persistActiveChatSession();
}
@@ -1441,7 +1690,7 @@ function removeAssistantMessage(msg) {
const index = chatHistory.indexOf(msg);
if (index >= 0) {
chatHistory.splice(index, 1);
renderChatHistory();
renderChatHistory(true);
}
}
@@ -1472,7 +1721,7 @@ async function sendChat() {
chatHistory.push({ role: "user", content: prompt, model: selectedChatModel });
const assistantMessage = { role: "assistant", content: "", model: selectedChatModel, streaming: true };
chatHistory.push(assistantMessage);
renderChatHistory();
renderChatHistory(true);
persistActiveChatSession();
renderChatStatus("sending request…");
let tokens = 0;
@@ -1555,7 +1804,7 @@ async function sendChat() {
removeAssistantMessage(assistantMessage);
const error = (err && err.message) || "request failed";
chatHistory.push({ role: "error", content: error, model: selectedChatModel });
renderChatHistory();
renderChatHistory(true);
persistActiveChatSession();
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) {
const r = await apiCall(
`/v1/proxy/requests/${encodeURIComponent(requestId)}/cancel`,
"POST",
{},
);
if (r.ok) refresh();
if (r.ok) await refreshCallWall();
}
$("call-wall").addEventListener("click", (event) => {
@@ -1616,74 +1849,166 @@ $("call-wall").addEventListener("click", (event) => {
if (requestId) cancelProxyRequest(requestId);
});
async function refresh() {
$("self-url").textContent = location.host;
const [raft, map, stats, models, consoleData, routing, adminData] = await Promise.all([
fetchJson("/v1/raft/status"),
fetchJson("/v1/network/map"),
fetchJson("/v1/stats"),
fetchJson("/v1/models"),
fetchJson("/v1/console"),
fetchJson("/v1/routing"),
isAdmin ? Promise.all([
fetchJson("/v1/billing/summary"),
fetchJson("/v1/billing/settlements"),
fetchJson("/v1/registry/wallets"),
]) : Promise.resolve([null, null, null]),
]);
const [summary, settlements, wallets] = adminData;
lastStats = stats;
lastRouting = routing;
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();
function applyAvailableModels(models, map) {
lastNetworkMap = map || lastNetworkMap;
availableModels = ((models && models.data) || []).map(model => {
const entry = {
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,
};
entry.servedCopies = modelServedCopiesFromMap(lastNetworkMap, entry);
return entry;
}).filter(model => model.id);
}
function markRefreshed() {
$("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() {
const sel = window.getSelection();
return sel && !sel.isCollapsed && sel.toString().length > 0;
}
async function refreshIfIdle() {
if (selectionActive()) return;
await refresh();
}
refresh();
bindChatSessionList();
bindChatModelSelect();
initChatSessions();
bindChatPromptShortcuts();
renderAccountPanel();
renderChatModels();
renderChatHistory();
renderChatAuthHint();
setInterval(refreshIfIdle, REFRESH_MS);
setInterval(() => {
if (!sessionToken && !isLoggedIn) return;
if (selectionActive()) return;
renderAccountPanel();
}, REFRESH_MS);
(async () => {
if (sessionToken) await loadAccountSummary(true);
else renderAuthForms();
await refreshActiveTab(true);
renderChatHistory(true);
renderChatAuthHint();
if (dashboardTab === "chat") renderChatModels(true);
})();
setInterval(pollCallWallIfIdle, CALL_WALL_POLL_MS);
</script>
</body>
</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": {
"layers_start": 0,
"layers_end": 39,

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
@@ -2686,6 +2707,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
self._handle_billing_summary()
elif parsed.path == "/v1/billing/settlements":
self._handle_billing_settlements()
elif parsed.path == "/v1/account/usage":
self._handle_account_usage(parsed)
elif parsed.path == "/v1/account":
self._handle_account_me()
elif parsed.path == "/v1/admin/accounts":
@@ -2923,6 +2946,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 +4025,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 +4064,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 +4083,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 +4198,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 +4485,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)})
@@ -4495,7 +4538,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
self._send_json(200, {"ok": True}, headers={"Set-Cookie": _session_cookie_header(None)})
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]
if self._require_accounts() is None:
return
@@ -4505,10 +4548,10 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
return
keys = server.accounts.keys_for(account["account_id"]) # type: ignore[union-attr]
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:
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, {
"account": account,
"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,
})
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):
accounts = self._require_accounts()
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
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 +6192,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(
@@ -166,6 +196,41 @@ def test_register_login_and_account_view(account_tracker):
assert me["api_keys"] == [reg["api_key"]]
assert me["total_balance"] == pytest.approx(0.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):

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()
@@ -63,11 +91,15 @@ def test_dashboard_chat_model_selector_shows_health_and_speed():
tracker.stop()
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
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():
@@ -81,12 +113,42 @@ def test_dashboard_chat_sessions_use_delegated_handlers():
tracker.stop()
assert "bindChatSessionList" in html
assert "data-session-id=" in html
assert "data-delete-session=" in html
assert "dataset.sessionId" in html
assert "dataset.deleteSession" in html
assert '[data-session-id]' in html
assert 'onclick="selectChatSession(' 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():
"""A tracker that is not the leader (unreachable peers → never elected)
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)
finally:
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)