Compare commits
4 Commits
436e872abe
...
d648da3344
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d648da3344 | ||
|
|
4a10eb6013 | ||
|
|
1e44e8e578 | ||
|
|
52629d7762 |
@@ -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")
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user