Compare commits
3 Commits
3eb7c6b93e
...
fdeb881c83
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdeb881c83 | ||
|
|
08e9c22ccf | ||
|
|
e81d989f39 |
BIN
billing.sqlite
BIN
billing.sqlite
Binary file not shown.
@@ -75,7 +75,7 @@ What exists already (build on it, don't duplicate):
|
||||
- [x] Node downloader keeps exact-shard peers first, then races tracker model
|
||||
sources against a HuggingFace `snapshot_download(..., allow_patterns=...)`
|
||||
subset download, using the first successful source.
|
||||
- [ ] When no tracker model source is available at all, the HuggingFace
|
||||
- [x] When no tracker model source is available at all, the HuggingFace
|
||||
fallback still computes `allow_patterns` from the repo's own
|
||||
`model.safetensors.index.json` (fetched directly, not via the tracker) —
|
||||
it never silently downloads the full model just because the tracker has
|
||||
@@ -95,7 +95,9 @@ What exists already (build on it, don't duplicate):
|
||||
|
||||
- 2026-07-06: Added the tracker/node download path. For immediate Qwen3.6-35B
|
||||
LAN testing, real PyTorch nodes fetch the full snapshot from the tracker via
|
||||
`full_url` and race HuggingFace as fallback. Remaining hard half is true
|
||||
partial model materialization: the backend can prefer a downloaded local
|
||||
model directory, but Transformers still needs a `meta`-device load path that
|
||||
materializes only assigned layers.
|
||||
`full_url`; HuggingFace remains fallback-only, and when it is used the node
|
||||
computes `allow_patterns` from the repo's remote SafeTensors index so it
|
||||
stays layer-filtered even without tracker-cached files. Remaining hard half
|
||||
is true partial model materialization: the backend can prefer a downloaded
|
||||
local model directory, but Transformers still needs a `meta`-device load
|
||||
path that materializes only assigned layers.
|
||||
|
||||
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
"""Shard downloader — fetches model shards from peers or HuggingFace Hub.
|
||||
"""Shard downloader — fetches model files from peers, tracker sources, or HuggingFace.
|
||||
|
||||
Cache layout: ~/.cache/meshnet/shards/<model>/
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
@@ -22,6 +23,10 @@ class InsufficientVRAMError(ModelBackendError):
|
||||
"""Raised when a requested shard cannot fit in available CUDA memory."""
|
||||
|
||||
|
||||
class PartialModelLoadUnsupported(ModelBackendError):
|
||||
"""Raised when a shard cannot be materialized from a local snapshot subset."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TensorPayload:
|
||||
body: bytes
|
||||
@@ -94,20 +99,39 @@ class TorchModelShard:
|
||||
None if load_source != model_id else cache_dir,
|
||||
)
|
||||
try:
|
||||
load_kwargs = {
|
||||
"device_map": "auto" if uses_quantized_weights else None,
|
||||
"dtype": dtype,
|
||||
"low_cpu_mem_usage": True,
|
||||
"cache_dir": str(cache_dir) if cache_dir is not None and load_source == model_id else None,
|
||||
}
|
||||
if quant_config is not None:
|
||||
load_kwargs["quantization_config"] = quant_config
|
||||
self.model = AutoModelForCausalLM.from_pretrained(
|
||||
total_layers_hint = _total_layers_for_local_snapshot(AutoConfig, load_source)
|
||||
if _should_partial_materialize_shard(
|
||||
load_source,
|
||||
**load_kwargs,
|
||||
)
|
||||
if not uses_quantized_weights:
|
||||
self.model.to(self.device)
|
||||
shard_start,
|
||||
shard_end,
|
||||
total_layers_hint=total_layers_hint,
|
||||
uses_quantized_weights=uses_quantized_weights,
|
||||
):
|
||||
self.model = _load_partial_model_from_snapshot(
|
||||
AutoConfig,
|
||||
AutoModelForCausalLM,
|
||||
torch,
|
||||
load_source,
|
||||
shard_start,
|
||||
shard_end,
|
||||
dtype,
|
||||
self.device,
|
||||
)
|
||||
else:
|
||||
load_kwargs = {
|
||||
"device_map": "auto" if uses_quantized_weights else None,
|
||||
"dtype": dtype,
|
||||
"low_cpu_mem_usage": True,
|
||||
"cache_dir": str(cache_dir) if cache_dir is not None and load_source == model_id else None,
|
||||
}
|
||||
if quant_config is not None:
|
||||
load_kwargs["quantization_config"] = quant_config
|
||||
self.model = AutoModelForCausalLM.from_pretrained(
|
||||
load_source,
|
||||
**load_kwargs,
|
||||
)
|
||||
if not uses_quantized_weights:
|
||||
self.model.to(self.device)
|
||||
except Exception as exc:
|
||||
if _looks_like_oom(exc):
|
||||
raise InsufficientVRAMError(
|
||||
@@ -357,6 +381,135 @@ def load_torch_shard(
|
||||
return TorchModelShard(model_id, shard_start, shard_end, quantization, cache_dir)
|
||||
|
||||
|
||||
def _total_layers_for_local_snapshot(auto_config: Any, load_source: str) -> int | None:
|
||||
snapshot_dir = Path(load_source)
|
||||
if not (snapshot_dir / "config.json").exists():
|
||||
return None
|
||||
from .model_catalog import layers_from_config
|
||||
|
||||
try:
|
||||
cfg = auto_config.from_pretrained(str(snapshot_dir))
|
||||
except Exception:
|
||||
return None
|
||||
return layers_from_config(cfg)
|
||||
|
||||
|
||||
def _should_partial_materialize_shard(
|
||||
load_source: str,
|
||||
shard_start: int,
|
||||
shard_end: int,
|
||||
*,
|
||||
total_layers_hint: int | None,
|
||||
uses_quantized_weights: bool,
|
||||
) -> bool:
|
||||
if uses_quantized_weights:
|
||||
return False
|
||||
snapshot_dir = Path(load_source)
|
||||
if not snapshot_dir.exists() or not (snapshot_dir / "config.json").exists():
|
||||
return False
|
||||
if not (snapshot_dir / "model.safetensors.index.json").exists():
|
||||
return False
|
||||
if total_layers_hint is None:
|
||||
return False
|
||||
return not (shard_start == 0 and shard_end >= total_layers_hint - 1)
|
||||
|
||||
|
||||
def _load_partial_model_from_snapshot(
|
||||
auto_config: Any,
|
||||
auto_model_for_causal_lm: Any,
|
||||
torch: Any,
|
||||
load_source: str,
|
||||
shard_start: int,
|
||||
shard_end: int,
|
||||
dtype: Any,
|
||||
device: Any,
|
||||
*,
|
||||
init_empty_weights_fn: Any | None = None,
|
||||
set_tensor_fn: Any | None = None,
|
||||
safe_open_fn: Any | None = None,
|
||||
) -> Any:
|
||||
from .model_catalog import layers_from_config
|
||||
from .safetensors_selection import (
|
||||
INDEX_FILENAME,
|
||||
select_tensor_names_for_layers_from_index,
|
||||
)
|
||||
|
||||
if init_empty_weights_fn is None:
|
||||
from accelerate import init_empty_weights as init_empty_weights_fn
|
||||
if set_tensor_fn is None:
|
||||
from accelerate.utils import set_module_tensor_to_device as set_tensor_fn
|
||||
if safe_open_fn is None:
|
||||
from safetensors import safe_open as safe_open_fn
|
||||
|
||||
snapshot_dir = Path(load_source)
|
||||
cfg = auto_config.from_pretrained(str(snapshot_dir))
|
||||
total_layers = layers_from_config(cfg)
|
||||
if total_layers is None:
|
||||
raise PartialModelLoadUnsupported(
|
||||
f"could not determine num_hidden_layers for local snapshot {snapshot_dir}"
|
||||
)
|
||||
if shard_end >= total_layers:
|
||||
raise ValueError(
|
||||
f"shard_end {shard_end} exceeds last layer index {total_layers - 1}"
|
||||
)
|
||||
|
||||
index_path = snapshot_dir / INDEX_FILENAME
|
||||
try:
|
||||
index = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
raise PartialModelLoadUnsupported(
|
||||
f"missing SafeTensors index for partial load: {index_path}"
|
||||
) from exc
|
||||
weight_map = index.get("weight_map")
|
||||
if not isinstance(weight_map, dict):
|
||||
raise PartialModelLoadUnsupported(f"{INDEX_FILENAME} must contain a weight_map object")
|
||||
|
||||
tensor_names = select_tensor_names_for_layers_from_index(
|
||||
weight_map,
|
||||
shard_start,
|
||||
shard_end,
|
||||
total_layers=total_layers,
|
||||
)
|
||||
if not tensor_names:
|
||||
raise PartialModelLoadUnsupported(
|
||||
f"no checkpoint tensors matched layers {shard_start}-{shard_end} in {snapshot_dir}"
|
||||
)
|
||||
|
||||
with init_empty_weights_fn():
|
||||
model = auto_model_for_causal_lm.from_config(cfg, torch_dtype=dtype)
|
||||
tie_weights = getattr(model, "tie_weights", None)
|
||||
if callable(tie_weights):
|
||||
tie_weights()
|
||||
|
||||
tensors_by_file: dict[str, list[str]] = {}
|
||||
for tensor_name in sorted(tensor_names):
|
||||
rel_file = weight_map.get(tensor_name)
|
||||
if not isinstance(rel_file, str):
|
||||
continue
|
||||
tensors_by_file.setdefault(rel_file, []).append(tensor_name)
|
||||
|
||||
for rel_file, names in tensors_by_file.items():
|
||||
checkpoint_file = snapshot_dir / rel_file
|
||||
if not checkpoint_file.exists():
|
||||
raise PartialModelLoadUnsupported(
|
||||
f"checkpoint file advertised in {INDEX_FILENAME} is missing: {checkpoint_file}"
|
||||
)
|
||||
with safe_open_fn(str(checkpoint_file), framework="pt", device="cpu") as handle:
|
||||
for tensor_name in names:
|
||||
set_tensor_fn(
|
||||
model,
|
||||
tensor_name,
|
||||
device,
|
||||
value=handle.get_tensor(tensor_name),
|
||||
dtype=dtype,
|
||||
)
|
||||
|
||||
for module in _active_modules_for_shard(model, shard_start, shard_end):
|
||||
if hasattr(module, "to"):
|
||||
module.to(device)
|
||||
return model
|
||||
|
||||
|
||||
def _model_load_plan(
|
||||
auto_config: Any,
|
||||
model_id: str,
|
||||
@@ -442,6 +595,37 @@ def _position_embeddings(model: Any) -> Any | None:
|
||||
return None
|
||||
|
||||
|
||||
def _rotary_embedding_module(model: Any) -> Any | None:
|
||||
if hasattr(model, "model") and hasattr(model.model, "rotary_emb"):
|
||||
return model.model.rotary_emb
|
||||
if hasattr(model, "transformer") and hasattr(model.transformer, "rotary_emb"):
|
||||
return model.transformer.rotary_emb
|
||||
return None
|
||||
|
||||
|
||||
def _active_modules_for_shard(model: Any, shard_start: int, shard_end: int) -> list[Any]:
|
||||
active: list[Any] = []
|
||||
|
||||
def add(module: Any | None) -> None:
|
||||
if module is None:
|
||||
return
|
||||
if any(existing is module for existing in active):
|
||||
return
|
||||
active.append(module)
|
||||
|
||||
if shard_start == 0:
|
||||
add(_embed_tokens(model))
|
||||
add(_position_embeddings(model))
|
||||
add(_rotary_embedding_module(model))
|
||||
for layer in _model_layers(model)[shard_start:shard_end + 1]:
|
||||
add(layer)
|
||||
total_layers = len(_model_layers(model))
|
||||
if shard_end >= total_layers - 1:
|
||||
add(_final_norm(model))
|
||||
add(getattr(model, "lm_head", None))
|
||||
return active
|
||||
|
||||
|
||||
def _final_norm(model: Any) -> Any | None:
|
||||
if hasattr(model, "model") and hasattr(model.model, "norm"):
|
||||
return model.model.norm
|
||||
@@ -485,11 +669,7 @@ def _rotary_position_embeddings(model: Any, hidden_states: Any, position_ids: An
|
||||
"""Return model-level rotary embeddings required by newer HF decoder layers."""
|
||||
if position_ids is None:
|
||||
return None
|
||||
rotary = None
|
||||
if hasattr(model, "model") and hasattr(model.model, "rotary_emb"):
|
||||
rotary = model.model.rotary_emb
|
||||
elif hasattr(model, "transformer") and hasattr(model.transformer, "rotary_emb"):
|
||||
rotary = model.transformer.rotary_emb
|
||||
rotary = _rotary_embedding_module(model)
|
||||
if rotary is None:
|
||||
return None
|
||||
return rotary(hidden_states, position_ids)
|
||||
|
||||
@@ -118,6 +118,23 @@ def select_files_for_layers_from_index(
|
||||
return selected
|
||||
|
||||
|
||||
def select_tensor_names_for_layers_from_index(
|
||||
weight_map: dict[str, str],
|
||||
start_layer: int,
|
||||
end_layer: int,
|
||||
*,
|
||||
total_layers: int | None = None,
|
||||
) -> set[str]:
|
||||
"""Pure variant that returns checkpoint tensor names instead of file paths."""
|
||||
selected: set[str] = set()
|
||||
for tensor_name, rel_file in weight_map.items():
|
||||
if not isinstance(tensor_name, str) or not isinstance(rel_file, str):
|
||||
continue
|
||||
if _tensor_belongs_to_range(tensor_name, start_layer, end_layer, total_layers):
|
||||
selected.add(tensor_name)
|
||||
return selected
|
||||
|
||||
|
||||
def _tensor_belongs_to_range(
|
||||
tensor_name: str,
|
||||
start_layer: int,
|
||||
|
||||
@@ -453,13 +453,13 @@ class BillingLedger:
|
||||
with self._lock:
|
||||
return self._node_pending.get(wallet, 0.0)
|
||||
|
||||
def usage_for(self, api_keys: list[str], *, recent_limit: int = 20) -> dict:
|
||||
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)
|
||||
requests = 0
|
||||
total_tokens = 0
|
||||
total_cost = 0.0
|
||||
recent: list[dict] = []
|
||||
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,18 +467,20 @@ class BillingLedger:
|
||||
requests += 1
|
||||
total_tokens += int(event.get("total_tokens", 0))
|
||||
total_cost += float(event.get("cost", 0.0))
|
||||
recent.append({
|
||||
records.append({
|
||||
"api_key": event["api_key"],
|
||||
"model": event.get("model"),
|
||||
"total_tokens": event.get("total_tokens", 0),
|
||||
"cost": event.get("cost", 0.0),
|
||||
"ts": event.get("ts", 0.0),
|
||||
})
|
||||
recent = records[-recent_limit:] if recent_limit is not None else records
|
||||
return {
|
||||
"requests": requests,
|
||||
"total_tokens": total_tokens,
|
||||
"total_cost": total_cost,
|
||||
"recent": recent[-recent_limit:],
|
||||
"records": records,
|
||||
"recent": recent,
|
||||
}
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
|
||||
@@ -39,12 +39,41 @@
|
||||
.form-row { display:flex; gap:8px; }
|
||||
.form-row button { white-space:nowrap; }
|
||||
.error-msg { color:var(--bad); font-size:12px; min-height:16px; }
|
||||
.keybox { word-break:break-all; background:var(--bg); border:1px solid var(--border);
|
||||
.keybox { display:flex; flex-wrap:wrap; align-items:center; gap:6px;
|
||||
position:relative;
|
||||
word-break:break-all; background:var(--bg); border:1px solid var(--border);
|
||||
border-radius:6px; padding:4px 8px; margin:4px 0; font-size:11px; }
|
||||
.key-text { cursor:text; flex:1 1 auto; min-width:12rem; }
|
||||
.copy-tooltip {
|
||||
position:absolute; right:8px; top:-26px;
|
||||
background:var(--panel); border:1px solid var(--border); color:var(--ok);
|
||||
padding:2px 8px; border-radius:4px; font-size:11px;
|
||||
pointer-events:none; z-index:1; white-space:nowrap;
|
||||
}
|
||||
.tabs { display:flex; gap:10px; margin-bottom:8px; }
|
||||
.tabs a { color:var(--dim); cursor:pointer; }
|
||||
.tabs a.active { color:var(--accent); border-bottom:1px solid var(--accent); }
|
||||
.dashboard-tabs { display:flex; gap:10px; padding:10px 20px 0; border-bottom:1px solid var(--border); }
|
||||
.dashboard-tabs button { border:0; border-bottom:1px solid transparent; border-radius:0;
|
||||
background:transparent; color:var(--dim); padding:5px 0 8px; }
|
||||
.dashboard-tabs button.active { color:var(--accent); border-bottom-color:var(--accent); }
|
||||
.wide { grid-column:1 / -1; }
|
||||
section[hidden] { display:none !important; }
|
||||
.chat-shell { display:grid; grid-template-columns:minmax(0, 1.35fr) minmax(320px, 0.65fr); gap:12px; }
|
||||
.chat-pane { display:flex; flex-direction:column; gap:10px; min-width:0; }
|
||||
.chat-panel { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:10px; }
|
||||
.chat-controls { display:flex; gap:10px; align-items:end; flex-wrap:wrap; }
|
||||
.chat-controls label { display:flex; flex-direction:column; gap:4px; color:var(--dim); }
|
||||
.chat-controls select { min-width:220px; }
|
||||
.chat-history { display:flex; flex-direction:column; gap:8px; min-height:220px; max-height:420px; overflow:auto; }
|
||||
.chat-message { border:1px solid #21262d; border-radius:6px; padding:8px 10px; background:#10151d; }
|
||||
.chat-role { color:var(--dim); font-size:11px; text-transform:uppercase; letter-spacing:.06em; margin-bottom:4px; }
|
||||
.chat-role-user { color:var(--accent); }
|
||||
.chat-role-assistant { color:var(--ok); }
|
||||
.chat-role-error { color:var(--bad); }
|
||||
.chat-compose { display:flex; flex-direction:column; gap:8px; }
|
||||
.chat-compose textarea { min-height:112px; resize:vertical; width:100%; }
|
||||
.chat-status { color:var(--dim); font-size:12px; }
|
||||
.console {
|
||||
background:var(--bg); border:1px solid var(--border); border-radius:6px;
|
||||
min-height:160px; max-height:280px; overflow:auto; padding:7px 9px;
|
||||
@@ -55,6 +84,10 @@
|
||||
.console-level-info { color:var(--accent); }
|
||||
.console-level-warn { color:var(--warn); }
|
||||
.console-level-error { color:var(--bad); }
|
||||
.status-pending { color:var(--warn); }
|
||||
.status-processing { color:var(--accent); }
|
||||
.status-failed { color:var(--bad); }
|
||||
.status-complete { color:var(--ok); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -63,19 +96,52 @@
|
||||
<span class="meta" id="self-url"></span>
|
||||
<span class="meta" id="refreshed"></span>
|
||||
</header>
|
||||
<nav class="dashboard-tabs" aria-label="Dashboard sections">
|
||||
<button id="tab-overview" class="active" onclick="switchDashboardTab('overview')">Overview</button>
|
||||
<button id="tab-chat" onclick="switchDashboardTab('chat')">Chat</button>
|
||||
<button id="tab-billing" style="display:none" onclick="switchDashboardTab('billing')">Billing</button>
|
||||
<button id="tab-admin" style="display:none" onclick="switchDashboardTab('admin')">Admin</button>
|
||||
</nav>
|
||||
<main>
|
||||
<section id="account-section"><h2>Account</h2><div id="account">loading…</div></section>
|
||||
<section id="admin-section" style="display:none"><h2>All accounts (admin)</h2><div id="admin" class="empty"></div></section>
|
||||
<section><h2>Tracker hive</h2><div id="hive" class="empty">loading…</div></section>
|
||||
<section><h2>Nodes & coverage</h2><div id="nodes" class="empty">loading…</div></section>
|
||||
<section><h2>Client balances</h2><div id="clients" class="empty">loading…</div></section>
|
||||
<section><h2>Node pending payouts</h2><div id="pending" class="empty">loading…</div></section>
|
||||
<section><h2>Settlement history</h2><div id="settlements" class="empty">loading…</div></section>
|
||||
<section><h2>Strikes / bans / forfeitures</h2><div id="fraud" class="empty">loading…</div></section>
|
||||
<section><h2>Model usage (RPM)</h2><div id="stats" class="empty">loading…</div></section>
|
||||
<section><h2>Node throughput</h2><div id="throughput" class="empty">loading…</div></section>
|
||||
<section class="wide"><h2>Console output</h2><div id="console" class="console empty">loading…</div></section>
|
||||
<section class="wide"><h2>Inference history</h2><div id="inference-history" class="empty">loading...</div></section>
|
||||
<section data-tab="overview" id="account-section"><h2>Account</h2><div id="account">loading…</div></section>
|
||||
<section data-tab="overview"><h2>Tracker hive</h2><div id="hive" class="empty">loading…</div></section>
|
||||
<section data-tab="overview"><h2>Nodes & coverage</h2><div id="nodes" class="empty">loading…</div></section>
|
||||
<section data-tab="overview"><h2>Model usage (RPM)</h2><div id="stats" class="empty">loading…</div></section>
|
||||
<section data-tab="overview" class="wide"><h2>Call wall</h2><div id="call-wall" class="empty">loading...</div></section>
|
||||
<section data-tab="chat" class="wide">
|
||||
<h2>Chat / inference</h2>
|
||||
<div class="chat-shell">
|
||||
<div class="chat-pane">
|
||||
<div class="chat-panel chat-controls">
|
||||
<label>Model
|
||||
<select id="chat-model" onchange="selectChatModel(this.value)"></select>
|
||||
</label>
|
||||
<button class="small" onclick="clearChatHistory()">clear history</button>
|
||||
</div>
|
||||
<div class="chat-panel chat-compose">
|
||||
<textarea id="chat-prompt" placeholder="Ask a question or describe the task"></textarea>
|
||||
<div class="form-row">
|
||||
<button onclick="sendChat()" id="chat-send">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-pane">
|
||||
<div class="chat-panel">
|
||||
<div id="chat-status" class="chat-status">select a model to start</div>
|
||||
<div id="chat-history" class="chat-history empty">no messages yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section data-tab="billing" data-logged-in-only><h2>Usage summary</h2><div id="usage-summary" class="empty">login required</div></section>
|
||||
<section data-tab="billing" data-logged-in-only><h2>Node throughput</h2><div id="node-throughput" class="empty">login required</div></section>
|
||||
<section data-tab="billing"><h2>Request history</h2><div id="billing-usage" class="empty">login required</div></section>
|
||||
<section data-tab="billing" data-admin-only><h2>Node pending payouts</h2><div id="pending" class="empty">admin login required</div></section>
|
||||
<section data-tab="billing" data-admin-only><h2>Settlement history</h2><div id="settlements" class="empty">admin login required</div></section>
|
||||
<section data-tab="admin" id="admin-section"><h2>All accounts (admin)</h2><div id="admin" class="empty"></div></section>
|
||||
<section data-tab="admin" data-admin-only><h2>Strikes / bans / forfeitures</h2><div id="fraud" class="empty">admin login required</div></section>
|
||||
<section data-tab="admin"><h2>Client balances</h2><div id="clients" class="empty">admin login required</div></section>
|
||||
<section data-tab="admin" class="wide"><h2>Console output</h2><div id="console" class="console empty">admin login required</div></section>
|
||||
</main>
|
||||
<script>
|
||||
"use strict";
|
||||
@@ -211,7 +277,7 @@ function renderStats(stats) {
|
||||
$("stats").innerHTML = table(["model", "rpm (1h)", "rpm (24h)", "rpm (30d)"], rows);
|
||||
}
|
||||
|
||||
function renderThroughput(stats) {
|
||||
function renderThroughputHtml(stats) {
|
||||
const nodes = (stats && stats.nodes) || {};
|
||||
const rows = [];
|
||||
for (const [nodeId, nodeStats] of Object.entries(nodes)) {
|
||||
@@ -224,72 +290,265 @@ function renderThroughput(stats) {
|
||||
]);
|
||||
}
|
||||
}
|
||||
$("throughput").innerHTML = table(["node", "model", "tps (1h)", "samples"], rows);
|
||||
if (!rows.length) return '<div class="empty">no throughput samples yet</div>';
|
||||
return table(["node", "model", "tps (1h)", "samples"], rows);
|
||||
}
|
||||
|
||||
function renderInferenceHistory(data) {
|
||||
const events = (data && data.events) || [];
|
||||
const started = new Map();
|
||||
const progress = new Map();
|
||||
const completed = [];
|
||||
function hiveThroughputSummary(stats) {
|
||||
const nodes = (stats && stats.nodes) || {};
|
||||
let totalTps = 0;
|
||||
let samples = 0;
|
||||
for (const nodeStats of Object.values(nodes)) {
|
||||
for (const s of Object.values((nodeStats && nodeStats.models) || {})) {
|
||||
const t = Number(s.tokens_per_sec_last_hour);
|
||||
if (Number.isFinite(t)) totalTps += t;
|
||||
samples += Number(s.sample_count_last_hour || 0);
|
||||
}
|
||||
}
|
||||
return { totalTps, samples };
|
||||
}
|
||||
|
||||
function buildCallWallStates(events) {
|
||||
const byId = new Map();
|
||||
for (const e of events) {
|
||||
const f = e.fields || {};
|
||||
const id = f.request_id;
|
||||
if (!id) continue;
|
||||
if (e.message === "proxy route selected") {
|
||||
started.set(id, e);
|
||||
} else if (e.message === "proxy progress") {
|
||||
progress.set(id, e);
|
||||
} else if (e.message === "proxy complete" || e.message === "proxy failed" || e.message === "direct proxy failed after relay") {
|
||||
completed.push(e);
|
||||
started.delete(id);
|
||||
progress.delete(id);
|
||||
let rec = byId.get(id);
|
||||
if (!rec) {
|
||||
rec = { id, events: [] };
|
||||
byId.set(id, rec);
|
||||
}
|
||||
rec.events.push(e);
|
||||
const msg = e.message;
|
||||
if (msg === "proxy route selected") {
|
||||
rec.status = "pending";
|
||||
rec.started = e.ts;
|
||||
rec.model = f.model || f.route_model || "?";
|
||||
rec.route = f.route || f.nodes;
|
||||
rec.nodes = f.nodes;
|
||||
rec.stream = f.stream;
|
||||
} else if (msg === "proxy via relay" || msg === "proxy connected") {
|
||||
rec.status = "processing";
|
||||
if (!rec.started) rec.started = e.ts;
|
||||
rec.model = rec.model || f.model || f.route_model || "?";
|
||||
} else if (msg === "proxy progress") {
|
||||
rec.status = "processing";
|
||||
rec.model = rec.model || f.model || f.route_model || "?";
|
||||
rec.tokens = f.tokens;
|
||||
rec.tps = f.tokens_per_sec;
|
||||
rec.elapsed = f.elapsed_seconds;
|
||||
rec.stream = f.stream;
|
||||
} else if (msg === "relay proxy failed, trying direct") {
|
||||
rec.status = "processing";
|
||||
rec.warn = "relay failed, trying direct";
|
||||
} else if (msg === "proxy complete") {
|
||||
rec.status = "complete";
|
||||
rec.model = rec.model || f.model || f.route_model || "?";
|
||||
rec.tokens = f.tokens;
|
||||
rec.tps = f.tokens_per_sec;
|
||||
rec.elapsed = f.elapsed_seconds;
|
||||
rec.stream = f.stream;
|
||||
rec.terminal = e;
|
||||
} else if (msg === "proxy failed" || msg === "direct proxy failed after relay") {
|
||||
rec.status = "failed";
|
||||
rec.model = rec.model || f.model || f.route_model || "?";
|
||||
rec.error = f.error || msg;
|
||||
rec.terminal = e;
|
||||
}
|
||||
}
|
||||
const activeByModel = {};
|
||||
let queuedEstimate = 0;
|
||||
const activeRows = [];
|
||||
for (const e of started.values()) {
|
||||
const f = e.fields || {};
|
||||
const model = f.model || f.route_model || "?";
|
||||
activeByModel[model] = (activeByModel[model] || 0) + 1;
|
||||
const p = (progress.get(f.request_id) || {}).fields || {};
|
||||
const nodeQueues = Array.isArray(f.nodes) ? f.nodes.map(n => Number(n.queue_depth || 0)) : [];
|
||||
const maxQueue = nodeQueues.length ? Math.max(...nodeQueues) : 0;
|
||||
queuedEstimate += Math.max(0, maxQueue - 1);
|
||||
activeRows.push([
|
||||
new Date((e.ts || 0) * 1000).toLocaleTimeString(),
|
||||
esc(short(model, 28)),
|
||||
esc(short(f.request_id || "?", 18)),
|
||||
`<span class="num">${esc(tps(p.tokens_per_sec))}</span>`,
|
||||
`<span class="num">${esc(String(p.tokens ?? 0))}</span>`,
|
||||
`<span class="num">${esc(String(maxQueue))}</span>`,
|
||||
p.stream ? "stream" : "json",
|
||||
]);
|
||||
return byId;
|
||||
}
|
||||
|
||||
function callWallAgeSeconds(rec, nowSec) {
|
||||
const start = rec.started || (rec.events[0] && rec.events[0].ts) || nowSec;
|
||||
return Math.max(0, nowSec - start);
|
||||
}
|
||||
|
||||
function callWallMaxQueue(rec) {
|
||||
const nodes = rec.nodes || [];
|
||||
const nodeQueues = Array.isArray(nodes) ? nodes.map(n => Number(n.queue_depth || 0)) : [];
|
||||
return nodeQueues.length ? Math.max(...nodeQueues) : 0;
|
||||
}
|
||||
|
||||
function renderCallWall(consoleData, stats) {
|
||||
const events = (consoleData && consoleData.events) || [];
|
||||
const nowSec = Date.now() / 1000;
|
||||
const states = buildCallWallStates(events);
|
||||
const active = [];
|
||||
const terminal = [];
|
||||
for (const rec of states.values()) {
|
||||
if (rec.status === "pending" || rec.status === "processing") active.push(rec);
|
||||
else if (rec.status === "complete" || rec.status === "failed") terminal.push(rec);
|
||||
}
|
||||
const active = Object.entries(activeByModel)
|
||||
.map(([model, count]) => `${esc(model)}: <span class="num">${count}</span> active`)
|
||||
.join(" · ");
|
||||
const liveSummary = active
|
||||
? `${active}${queuedEstimate ? ` · queued estimate: <span class="num">${queuedEstimate}</span>` : ""}`
|
||||
: "no active requests";
|
||||
const rows = completed.slice(-20).reverse().map(e => {
|
||||
active.sort((a, b) => (a.started || 0) - (b.started || 0));
|
||||
terminal.sort((a, b) => (b.terminal && b.terminal.ts) - (a.terminal && a.terminal.ts));
|
||||
|
||||
const hive = hiveThroughputSummary(stats);
|
||||
const pending = active.filter(r => r.status === "pending").length;
|
||||
const processing = active.filter(r => r.status === "processing").length;
|
||||
const failedRecent = terminal.filter(r => r.status === "failed").length;
|
||||
let queuedEstimate = 0;
|
||||
for (const rec of active) queuedEstimate += Math.max(0, callWallMaxQueue(rec) - 1);
|
||||
|
||||
let html =
|
||||
`<div class="dim" style="margin-bottom:6px">` +
|
||||
`hive tps (1h): <b>${esc(tps(hive.totalTps))}</b> · samples: <b>${hive.samples}</b> · ` +
|
||||
`active: <span class="status-processing">${processing}</span> processing · ` +
|
||||
`<span class="status-pending">${pending}</span> pending` +
|
||||
(queuedEstimate ? ` · queued estimate: <b>${queuedEstimate}</b>` : "") +
|
||||
(failedRecent ? ` · <span class="status-failed">${failedRecent} recent failures</span>` : "") +
|
||||
`</div>`;
|
||||
|
||||
if (active.length) {
|
||||
html += table(["status", "age", "model", "request", "live tps", "tokens", "queue", "route / note"], active.map(rec => {
|
||||
const statusCls = rec.status === "pending" ? "status-pending" : "status-processing";
|
||||
const note = rec.warn || (rec.route ? short(String(rec.route), 28) : "");
|
||||
return [
|
||||
`<span class="${statusCls}">${esc(rec.status)}</span>`,
|
||||
`<span class="num">${esc(callWallAgeSeconds(rec, nowSec).toFixed(1))}s</span>`,
|
||||
esc(short(rec.model || "?", 28)),
|
||||
esc(short(rec.id, 18)),
|
||||
`<span class="num">${esc(tps(rec.tps))}</span>`,
|
||||
`<span class="num">${esc(String(rec.tokens ?? "—"))}</span>`,
|
||||
`<span class="num">${esc(String(callWallMaxQueue(rec)))}</span>`,
|
||||
esc(note),
|
||||
];
|
||||
}));
|
||||
} else {
|
||||
html += '<div class="empty">no in-flight requests</div>';
|
||||
}
|
||||
|
||||
const historyRows = terminal.slice(0, 40).map(rec => {
|
||||
const e = rec.terminal || {};
|
||||
const f = e.fields || {};
|
||||
const statusCls = rec.status === "failed" ? "status-failed" : "status-complete";
|
||||
const detail = rec.status === "failed"
|
||||
? esc(short(rec.error || "?", 40))
|
||||
: (f.stream ? "stream" : "json");
|
||||
return [
|
||||
new Date((e.ts || 0) * 1000).toLocaleTimeString(),
|
||||
esc(short(f.model || f.route_model || "?", 28)),
|
||||
esc(short(f.request_id || "?", 18)),
|
||||
`<span class="num">${esc(tps(f.tokens_per_sec))}</span>`,
|
||||
`<span class="num">${esc(String(f.tokens ?? "?"))}</span>`,
|
||||
`<span class="num">${esc(String(f.elapsed_seconds ?? "?"))}</span>`,
|
||||
f.stream ? "stream" : "json",
|
||||
`<span class="${statusCls}">${esc(rec.status)}</span>`,
|
||||
esc(short(rec.model || "?", 28)),
|
||||
esc(short(rec.id, 18)),
|
||||
`<span class="num">${esc(tps(rec.tps ?? f.tokens_per_sec))}</span>`,
|
||||
`<span class="num">${esc(String(rec.tokens ?? f.tokens ?? "?"))}</span>`,
|
||||
`<span class="num">${esc(String(rec.elapsed ?? f.elapsed_seconds ?? "?"))}</span>`,
|
||||
detail,
|
||||
];
|
||||
});
|
||||
$("inference-history").innerHTML =
|
||||
`<div class="dim" style="margin-bottom:6px">${liveSummary}</div>` +
|
||||
(activeRows.length ? table(["started", "model", "request", "live tps", "tokens", "queue", "mode"], activeRows.reverse()) : "") +
|
||||
(rows.length ? table(["time", "model", "request", "tps", "tokens", "sec", "mode"], rows)
|
||||
: '<div class="empty">no completed inference requests</div>');
|
||||
html += '<div style="margin-top:8px"><b class="dim">recent completed / failed</b></div>';
|
||||
html += historyRows.length
|
||||
? table(["time", "status", "model", "request", "tps", "tokens", "sec", "detail"], historyRows)
|
||||
: '<div class="empty">no completed requests yet</div>';
|
||||
$("call-wall").innerHTML = html;
|
||||
}
|
||||
|
||||
function startOfLocalDay(tsSec) {
|
||||
const d = new Date(tsSec * 1000);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime() / 1000;
|
||||
}
|
||||
|
||||
function formatUsageDayLabel(tsSec) {
|
||||
return new Date(tsSec * 1000).toLocaleDateString();
|
||||
}
|
||||
|
||||
function summarizeUsageBuckets(records) {
|
||||
const now = Date.now() / 1000;
|
||||
const todayStart = startOfLocalDay(now);
|
||||
const daySec = 86400;
|
||||
const empty = () => ({ requests: 0, tokens: 0, cost: 0 });
|
||||
const daily = [0, 1, 2].map(offset => ({
|
||||
label: offset === 0 ? "Today" : offset === 1 ? "Yesterday" : formatUsageDayLabel(todayStart - offset * daySec),
|
||||
...empty(),
|
||||
}));
|
||||
const last7 = { label: "Last 7 days", ...empty() };
|
||||
const last30 = { label: "Last 30 days", ...empty() };
|
||||
const total = { label: "All time", ...empty() };
|
||||
|
||||
for (const u of records) {
|
||||
const ts = Number(u.ts || 0);
|
||||
const tokens = Number(u.total_tokens || 0);
|
||||
const cost = Number(u.cost || 0);
|
||||
total.requests += 1;
|
||||
total.tokens += tokens;
|
||||
total.cost += cost;
|
||||
if (ts >= now - 30 * daySec) {
|
||||
last30.requests += 1;
|
||||
last30.tokens += tokens;
|
||||
last30.cost += cost;
|
||||
}
|
||||
if (ts >= now - 7 * daySec) {
|
||||
last7.requests += 1;
|
||||
last7.tokens += tokens;
|
||||
last7.cost += cost;
|
||||
}
|
||||
for (let offset = 0; offset < 3; offset++) {
|
||||
const start = todayStart - offset * daySec;
|
||||
const end = start + daySec;
|
||||
if (ts >= start && ts < end) {
|
||||
daily[offset].requests += 1;
|
||||
daily[offset].tokens += tokens;
|
||||
daily[offset].cost += cost;
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...daily, last7, last30, total];
|
||||
}
|
||||
|
||||
function renderUsageSummary(records) {
|
||||
const el = $("usage-summary");
|
||||
if (!el) return;
|
||||
if (!sessionToken) {
|
||||
el.innerHTML = '<div class="empty">login required</div>';
|
||||
return;
|
||||
}
|
||||
if (!records.length) {
|
||||
el.innerHTML = '<div class="empty">no billed requests yet</div>';
|
||||
return;
|
||||
}
|
||||
const rows = summarizeUsageBuckets(records).map(b => [
|
||||
esc(b.label),
|
||||
`<span class="num">${b.requests}</span>`,
|
||||
`<span class="num">${esc(String(b.tokens))}</span>`,
|
||||
`<span class="num">${usdt(b.cost)}</span>`,
|
||||
]);
|
||||
el.innerHTML =
|
||||
'<div class="dim" style="margin-bottom:6px">per-request detail on Request history below</div>' +
|
||||
table(["period", "requests", "tokens", "cost (USDT)"], rows);
|
||||
}
|
||||
|
||||
function renderNodeThroughput(stats) {
|
||||
const el = $("node-throughput");
|
||||
if (!el) return;
|
||||
if (!sessionToken) {
|
||||
el.innerHTML = '<div class="empty">login required</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = renderThroughputHtml(stats);
|
||||
}
|
||||
|
||||
function renderBillingUsage(records) {
|
||||
const el = $("billing-usage");
|
||||
if (!el) return;
|
||||
if (!sessionToken) {
|
||||
el.innerHTML = '<div class="empty">login required</div>';
|
||||
return;
|
||||
}
|
||||
if (!records.length) {
|
||||
el.innerHTML = '<div class="empty">no billed requests yet</div>';
|
||||
return;
|
||||
}
|
||||
const rows = records.slice().reverse().map(u => [
|
||||
new Date((u.ts || 0) * 1000).toLocaleString(),
|
||||
esc(short(u.model || "?", 28)),
|
||||
esc(short(u.api_key || "?", 14)),
|
||||
`<span class="num">${esc(String(u.total_tokens))}</span>`,
|
||||
`<span class="num">${usdt(u.cost)}</span>`,
|
||||
]);
|
||||
el.innerHTML = `<div class="dim" style="margin-bottom:6px">${records.length} request${records.length === 1 ? "" : "s"}</div>` +
|
||||
table(["time", "model", "api key", "tokens", "cost (USDT)"], rows);
|
||||
}
|
||||
|
||||
function renderConsole(data) {
|
||||
@@ -311,10 +570,126 @@ function renderConsole(data) {
|
||||
|
||||
let sessionToken = localStorage.getItem("meshnet_session") || null;
|
||||
let authTab = "login";
|
||||
let dashboardTab = "overview";
|
||||
let isAdmin = false;
|
||||
let isLoggedIn = false;
|
||||
let accountApiKeys = [];
|
||||
let accountUsageRecords = [];
|
||||
let lastStats = null;
|
||||
let availableModels = [];
|
||||
let chatHistory = [];
|
||||
let chatBusy = false;
|
||||
let selectedChatModel = localStorage.getItem("meshnet_chat_model") || "";
|
||||
|
||||
async function apiCall(path, method, body) {
|
||||
function switchDashboardTab(name) {
|
||||
if (name === "admin" && !isAdmin) name = "overview";
|
||||
if (name === "billing" && !isLoggedIn) name = "overview";
|
||||
dashboardTab = name;
|
||||
updateSectionVisibility();
|
||||
for (const tabName of ["overview", "chat", "billing", "admin"]) {
|
||||
const button = $("tab-" + tabName);
|
||||
if (button) button.classList.toggle("active", tabName === dashboardTab);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSectionVisibility() {
|
||||
for (const section of document.querySelectorAll("main section[data-tab]")) {
|
||||
const onTab = section.dataset.tab === dashboardTab;
|
||||
const adminOnly = section.hasAttribute("data-admin-only");
|
||||
const loggedInOnly = section.hasAttribute("data-logged-in-only");
|
||||
section.hidden = !onTab || (adminOnly && !isAdmin) || (loggedInOnly && !isLoggedIn);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChatStatus(text) {
|
||||
$("chat-status").textContent = text;
|
||||
}
|
||||
|
||||
function renderChatHistory() {
|
||||
const history = $("chat-history");
|
||||
if (!chatHistory.length) {
|
||||
history.classList.add("empty");
|
||||
history.innerHTML = "no messages yet";
|
||||
return;
|
||||
}
|
||||
history.classList.remove("empty");
|
||||
history.innerHTML = chatHistory.map(msg => {
|
||||
const roleClass = msg.role === "user" ? "chat-role-user" : msg.role === "assistant" ? "chat-role-assistant" : "chat-role-error";
|
||||
const label = msg.role === "user" ? "user" : msg.role === "assistant" ? "assistant" : "error";
|
||||
const meta = msg.model ? ` <span class="dim">· ${esc(short(msg.model, 24))}</span>` : "";
|
||||
return `<div class="chat-message"><div class="chat-role ${roleClass}">${label}${meta}</div><div>${esc(msg.content)}</div></div>`;
|
||||
}).join("");
|
||||
history.scrollTop = history.scrollHeight;
|
||||
}
|
||||
|
||||
function renderChatModels() {
|
||||
const select = $("chat-model");
|
||||
if (!select) return;
|
||||
const models = availableModels.slice();
|
||||
if (!models.length) {
|
||||
select.innerHTML = '<option value="">no models available</option>';
|
||||
select.disabled = true;
|
||||
return;
|
||||
}
|
||||
select.disabled = false;
|
||||
const preferred = models.find(m => m.id === selectedChatModel)
|
||||
|| models[0];
|
||||
selectedChatModel = preferred.id;
|
||||
localStorage.setItem("meshnet_chat_model", selectedChatModel);
|
||||
select.innerHTML = models.map(model => {
|
||||
const label = model.name && model.name !== model.id
|
||||
? `${model.name} (${model.id})`
|
||||
: model.id;
|
||||
const suffix = model.recommended ? " [recommended]" : "";
|
||||
return `<option value="${esc(model.id)}"${model.id === selectedChatModel ? " selected" : ""}>${esc(label + suffix)}</option>`;
|
||||
}).join("");
|
||||
select.value = selectedChatModel;
|
||||
}
|
||||
|
||||
function selectChatModel(value) {
|
||||
selectedChatModel = value || "";
|
||||
localStorage.setItem("meshnet_chat_model", selectedChatModel);
|
||||
}
|
||||
|
||||
function clearChatHistory() {
|
||||
chatHistory = [];
|
||||
renderChatHistory();
|
||||
renderChatStatus("history cleared");
|
||||
}
|
||||
|
||||
function chatAuthToken() {
|
||||
if (accountApiKeys.length) return accountApiKeys[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
function setAdminMode(enabled) {
|
||||
isAdmin = enabled;
|
||||
$("tab-admin").style.display = enabled ? "" : "none";
|
||||
if (!enabled && dashboardTab === "admin") {
|
||||
switchDashboardTab("overview");
|
||||
} else {
|
||||
updateSectionVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
function setLoggedInMode(enabled) {
|
||||
isLoggedIn = enabled;
|
||||
$("tab-billing").style.display = enabled ? "" : "none";
|
||||
if (!enabled) {
|
||||
accountUsageRecords = [];
|
||||
renderBillingUsage([]);
|
||||
renderUsageSummary([]);
|
||||
renderNodeThroughput(null);
|
||||
if (dashboardTab === "billing") switchDashboardTab("overview");
|
||||
} else {
|
||||
updateSectionVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
async function apiCall(path, method, body, bearerToken) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (sessionToken) headers["Authorization"] = "Bearer " + sessionToken;
|
||||
const token = bearerToken === undefined ? sessionToken : bearerToken;
|
||||
if (token) headers["Authorization"] = "Bearer " + token;
|
||||
try {
|
||||
const r = await fetch(path, {
|
||||
method: method || "GET",
|
||||
@@ -350,7 +725,10 @@ function renderAuthForms(errorMsg) {
|
||||
$("account").innerHTML =
|
||||
`<div class="tabs">${tab("login", "Log in")}${tab("register", "Register")}</div>` +
|
||||
form + `<div class="error-msg">${errorMsg ? esc(errorMsg) : ""}</div>`;
|
||||
$("admin-section").style.display = "none";
|
||||
accountApiKeys = [];
|
||||
renderChatAuthHint();
|
||||
setLoggedInMode(false);
|
||||
setAdminMode(false);
|
||||
}
|
||||
|
||||
function switchAuthTab(name) { authTab = name; renderAuthForms(); }
|
||||
@@ -398,15 +776,82 @@ async function topupKey(key) {
|
||||
await renderAccountPanel();
|
||||
}
|
||||
|
||||
const COPY_TOOLTIP_MS = 2000;
|
||||
|
||||
function showCopiedTooltip(anchor) {
|
||||
const box = (anchor && anchor.closest && anchor.closest(".keybox")) || anchor;
|
||||
if (!box) return;
|
||||
const existing = box.querySelector(".copy-tooltip");
|
||||
if (existing) existing.remove();
|
||||
const tip = document.createElement("span");
|
||||
tip.className = "copy-tooltip";
|
||||
tip.textContent = "Copied!";
|
||||
tip.setAttribute("role", "status");
|
||||
box.appendChild(tip);
|
||||
setTimeout(() => tip.remove(), COPY_TOOLTIP_MS);
|
||||
}
|
||||
|
||||
async function copyApiKeyText(text, anchor) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.left = "-9999px";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand("copy"); } catch { /* ignore */ }
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
if (anchor) showCopiedTooltip(anchor);
|
||||
}
|
||||
|
||||
function selectApiKeyText(el) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
if (!sel) return;
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
function copyApiKeyFromTextEl(el) {
|
||||
const key = el.dataset.key || el.textContent || "";
|
||||
return copyApiKeyText(key, el);
|
||||
}
|
||||
|
||||
function copyApiKeyFromButton(button) {
|
||||
const el = button.closest(".keybox") && button.closest(".keybox").querySelector(".key-text");
|
||||
const key = (el && el.dataset.key) || "";
|
||||
return copyApiKeyText(key, button);
|
||||
}
|
||||
|
||||
function renderChatAuthHint() {
|
||||
if (chatAuthToken()) {
|
||||
renderChatStatus("ready to send with your active API key");
|
||||
} else if (sessionToken) {
|
||||
renderChatStatus("create an API key in Account to use chat on a billing-enabled tracker");
|
||||
} else {
|
||||
renderChatStatus("log in if this tracker requires an API key");
|
||||
}
|
||||
}
|
||||
|
||||
async function renderAccountPanel() {
|
||||
const r = await apiCall("/v1/account");
|
||||
if (r.status === 404) { // accounts disabled on this tracker
|
||||
$("account-section").style.display = "none";
|
||||
$("admin-section").style.display = "none";
|
||||
accountApiKeys = [];
|
||||
accountUsageRecords = [];
|
||||
renderChatAuthHint();
|
||||
setLoggedInMode(false);
|
||||
setAdminMode(false);
|
||||
return;
|
||||
}
|
||||
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> ` +
|
||||
@@ -418,8 +863,10 @@ async function renderAccountPanel() {
|
||||
'<button class="small" onclick="createKey()">+ new key</button></div>';
|
||||
if (api_keys.length) {
|
||||
for (const key of api_keys) {
|
||||
html += `<div class="keybox">${esc(key)}` +
|
||||
` <span class="dim">(${usdt(balances[key] ?? 0)} USDT)</span>` +
|
||||
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>`
|
||||
: "") +
|
||||
@@ -428,24 +875,74 @@ async function renderAccountPanel() {
|
||||
} else {
|
||||
html += '<div class="empty">no active keys</div>';
|
||||
}
|
||||
if (usage.recent && usage.recent.length) {
|
||||
html += '<div style="margin-top:6px"><b class="dim">recent usage</b></div>' +
|
||||
table(["time", "model", "tokens", "cost"], usage.recent.slice().reverse().map(u => [
|
||||
new Date(u.ts * 1000).toLocaleTimeString(),
|
||||
esc(short(u.model || "?", 24)),
|
||||
`<span class="num">${esc(String(u.total_tokens))}</span>`,
|
||||
`<span class="num">${usdt(u.cost)}</span>`,
|
||||
]));
|
||||
}
|
||||
$("account").innerHTML = html;
|
||||
renderUsageSummary(accountUsageRecords);
|
||||
renderNodeThroughput(lastStats);
|
||||
renderBillingUsage(accountUsageRecords);
|
||||
renderChatAuthHint();
|
||||
renderChatModels();
|
||||
renderChatHistory();
|
||||
setLoggedInMode(true);
|
||||
setAdminMode(account.role === "admin");
|
||||
if (account.role === "admin") await renderAdminPanel();
|
||||
else $("admin-section").style.display = "none";
|
||||
}
|
||||
|
||||
async function sendChat() {
|
||||
const promptEl = $("chat-prompt");
|
||||
const prompt = promptEl.value.trim();
|
||||
if (!prompt || chatBusy) return;
|
||||
if (!selectedChatModel) {
|
||||
renderChatStatus("select a model first");
|
||||
return;
|
||||
}
|
||||
const bearerToken = chatAuthToken();
|
||||
const body = {
|
||||
model: selectedChatModel,
|
||||
messages: [
|
||||
...chatHistory
|
||||
.filter(msg => msg.role === "user" || msg.role === "assistant")
|
||||
.map(msg => ({ role: msg.role, content: msg.content })),
|
||||
{ role: "user", content: prompt },
|
||||
],
|
||||
stream: false,
|
||||
max_tokens: 256,
|
||||
};
|
||||
chatBusy = true;
|
||||
$("chat-send").disabled = true;
|
||||
promptEl.value = "";
|
||||
chatHistory.push({ role: "user", content: prompt, model: selectedChatModel });
|
||||
renderChatHistory();
|
||||
renderChatStatus("sending request…");
|
||||
const r = await apiCall("/v1/chat/completions", "POST", body, bearerToken);
|
||||
chatBusy = false;
|
||||
$("chat-send").disabled = false;
|
||||
if (!r.ok) {
|
||||
const error = r.data && r.data.error
|
||||
? (typeof r.data.error === "string" ? r.data.error : r.data.error.message || "request failed")
|
||||
: "request failed";
|
||||
chatHistory.push({ role: "error", content: error, model: selectedChatModel });
|
||||
renderChatHistory();
|
||||
renderChatStatus(error);
|
||||
promptEl.focus();
|
||||
return;
|
||||
}
|
||||
const reply = (r.data && r.data.choices && r.data.choices[0] && r.data.choices[0].message && r.data.choices[0].message.content) || "";
|
||||
const usage = r.data && r.data.usage;
|
||||
chatHistory.push({
|
||||
role: "assistant",
|
||||
content: reply || "(empty response)",
|
||||
model: selectedChatModel,
|
||||
});
|
||||
renderChatHistory();
|
||||
renderChatStatus(usage
|
||||
? `done: ${usage.total_tokens ?? "?"} tokens`
|
||||
: "done");
|
||||
promptEl.focus();
|
||||
}
|
||||
|
||||
async function renderAdminPanel() {
|
||||
const r = await apiCall("/v1/admin/accounts");
|
||||
if (!r.ok) { $("admin-section").style.display = "none"; return; }
|
||||
$("admin-section").style.display = "";
|
||||
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 [
|
||||
@@ -461,28 +958,44 @@ async function renderAdminPanel() {
|
||||
|
||||
async function refresh() {
|
||||
$("self-url").textContent = location.host;
|
||||
const [raft, map, summary, settlements, wallets, stats, consoleData] = await Promise.all([
|
||||
const [raft, map, stats, models, consoleData, adminData] = await Promise.all([
|
||||
fetchJson("/v1/raft/status"),
|
||||
fetchJson("/v1/network/map"),
|
||||
fetchJson("/v1/billing/summary"),
|
||||
fetchJson("/v1/billing/settlements"),
|
||||
fetchJson("/v1/registry/wallets"),
|
||||
fetchJson("/v1/stats"),
|
||||
fetchJson("/v1/models"),
|
||||
fetchJson("/v1/console"),
|
||||
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;
|
||||
availableModels = ((models && models.data) || []).map(model => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
recommended: Boolean(model.recommended),
|
||||
aliases: model.aliases || [],
|
||||
})).filter(model => model.id);
|
||||
renderHive(raft);
|
||||
renderNodes(map);
|
||||
renderBilling(summary);
|
||||
renderSettlements(settlements);
|
||||
renderFraud(wallets, summary);
|
||||
renderStats(stats);
|
||||
renderThroughput(stats);
|
||||
renderInferenceHistory(consoleData);
|
||||
renderCallWall(consoleData, stats);
|
||||
renderConsole(consoleData);
|
||||
renderNodeThroughput(stats);
|
||||
renderChatModels();
|
||||
renderChatHistory();
|
||||
$("refreshed").textContent = "refreshed " + new Date().toLocaleTimeString();
|
||||
}
|
||||
refresh();
|
||||
renderAccountPanel();
|
||||
renderChatModels();
|
||||
renderChatHistory();
|
||||
renderChatAuthHint();
|
||||
setInterval(refresh, 4000);
|
||||
setInterval(() => { if (sessionToken) renderAccountPanel(); }, 8000);
|
||||
</script>
|
||||
|
||||
@@ -544,6 +544,7 @@ class _NodeEntry:
|
||||
"relay_addr", "cert_fingerprint", "peer_id",
|
||||
# heartbeat stats (reported by node, cumulative)
|
||||
"total_requests", "failed_requests", "queue_depth", "proxy_inflight", "uptime_seconds",
|
||||
"current_requests",
|
||||
"status", # "ready" | "loading"
|
||||
"heartbeats_expected", "heartbeats_received",
|
||||
# dynamic reassignment queued by the tracker
|
||||
@@ -608,6 +609,7 @@ class _NodeEntry:
|
||||
self.failed_requests: int = 0
|
||||
self.queue_depth: int = 0
|
||||
self.proxy_inflight: int = 0
|
||||
self.current_requests: list[dict] = []
|
||||
self.uptime_seconds: float = 0.0
|
||||
self.status: str = "ready"
|
||||
self.heartbeats_expected: int = 0
|
||||
@@ -634,6 +636,40 @@ def _effective_queue_depth(node: "_NodeEntry") -> int:
|
||||
return max(node.queue_depth, node.proxy_inflight)
|
||||
|
||||
|
||||
_CURRENT_REQUEST_FIELDS = frozenset({
|
||||
"request_id", "model", "kind", "tokens", "tokens_per_sec",
|
||||
"elapsed_seconds", "routing_complete",
|
||||
})
|
||||
|
||||
|
||||
def _normalize_current_requests(items: object, *, limit: int = 32) -> list[dict]:
|
||||
"""Sanitize node-reported in-flight request snapshots from heartbeats."""
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
out: list[dict] = []
|
||||
for item in items[:limit]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
request_id = item.get("request_id")
|
||||
if not request_id:
|
||||
continue
|
||||
rec: dict = {"request_id": str(request_id)}
|
||||
for key in _CURRENT_REQUEST_FIELDS:
|
||||
if key == "request_id" or key not in item:
|
||||
continue
|
||||
value = item[key]
|
||||
if key in {"tokens"}:
|
||||
rec[key] = int(value)
|
||||
elif key in {"tokens_per_sec", "elapsed_seconds"}:
|
||||
rec[key] = float(value)
|
||||
elif key == "routing_complete":
|
||||
rec[key] = bool(value)
|
||||
else:
|
||||
rec[key] = str(value)
|
||||
out.append(rec)
|
||||
return out
|
||||
|
||||
|
||||
def _record_proxy_inflight(
|
||||
server: "_TrackerHTTPServer",
|
||||
nodes: list["_NodeEntry"],
|
||||
@@ -712,6 +748,7 @@ def _node_route_summary(nodes: list["_NodeEntry"]) -> list[dict]:
|
||||
"queue_depth": _effective_queue_depth(node),
|
||||
"heartbeat_queue_depth": node.queue_depth,
|
||||
"proxy_inflight": node.proxy_inflight,
|
||||
"current_requests": list(node.current_requests),
|
||||
}
|
||||
for node in nodes
|
||||
]
|
||||
@@ -992,6 +1029,7 @@ def _node_health(node: "_NodeEntry", heartbeat_timeout: float) -> dict:
|
||||
"queue_depth": _effective_queue_depth(node),
|
||||
"heartbeat_queue_depth": node.queue_depth,
|
||||
"proxy_inflight": node.proxy_inflight,
|
||||
"current_requests": list(node.current_requests),
|
||||
"total_requests": node.total_requests,
|
||||
"heartbeat_success_rate": hb_rate,
|
||||
"inference_success_rate": inf_rate,
|
||||
@@ -2479,6 +2517,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
node = max(candidates, key=lambda n: _effective_throughput(n, model))
|
||||
target_url = f"{node.endpoint}/v1/chat/completions"
|
||||
request_id = str(body.get("id") or f"req-{time.time_ns():x}")
|
||||
body["id"] = request_id
|
||||
raw_body = json.dumps(body).encode()
|
||||
|
||||
# Pre-resolve the downstream route so the first-shard node skips its own
|
||||
# tracker query. We already hold the full registry picture — no need for
|
||||
@@ -3312,6 +3352,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
entry.failed_requests = int(body["failed_requests"])
|
||||
if "queue_depth" in body:
|
||||
entry.queue_depth = int(body["queue_depth"])
|
||||
if "current_requests" in body:
|
||||
entry.current_requests = _normalize_current_requests(body["current_requests"])
|
||||
if "uptime_seconds" in body:
|
||||
entry.uptime_seconds = float(body["uptime_seconds"])
|
||||
if "status" in body and body["status"] in ("ready", "loading"):
|
||||
@@ -3593,7 +3635,7 @@ 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": []}
|
||||
usage: dict = {"requests": 0, "total_tokens": 0, "total_cost": 0.0, "recent": [], "records": []}
|
||||
if server.billing is not None:
|
||||
balances = {key: server.billing.get_client_balance(key) for key in keys}
|
||||
usage = server.billing.usage_for(keys)
|
||||
|
||||
@@ -12,7 +12,9 @@ from meshnet_tracker.server import TrackerServer
|
||||
PANELS = [
|
||||
"Tracker hive", "Nodes & coverage", "Client balances",
|
||||
"Node pending payouts", "Settlement history",
|
||||
"Strikes / bans / forfeitures", "Model usage", "Node throughput",
|
||||
"Strikes / bans / forfeitures", "Model usage", "Call wall",
|
||||
"Usage summary", "Node throughput", "Request history",
|
||||
"Chat / inference",
|
||||
"Console output",
|
||||
]
|
||||
|
||||
|
||||
@@ -566,6 +566,78 @@ def test_download_shard_prefers_tracker_model_source_over_huggingface(
|
||||
assert hf_calls == []
|
||||
|
||||
|
||||
def test_download_shard_prefers_tracker_full_model_source_over_huggingface(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""A tracker-advertised full snapshot is sufficient on its own — HF is never contacted."""
|
||||
contents = {
|
||||
"config.json": b"{}",
|
||||
"weights-a.safetensors": b"tracker-a",
|
||||
"weights-b.safetensors": b"tracker-b",
|
||||
}
|
||||
|
||||
class FakeFileResponse:
|
||||
def __init__(self, payload: bytes):
|
||||
self._payload = io.BytesIO(payload)
|
||||
self._length = len(payload)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def getheader(self, name: str):
|
||||
if name == "Content-Length":
|
||||
return str(self._length)
|
||||
if name == "Content-Type":
|
||||
return "application/octet-stream"
|
||||
return None
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
return self._payload.read(size)
|
||||
|
||||
def fake_urlopen(url, *args, **kwargs):
|
||||
query = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
|
||||
rel = query.get("file", [None])[0]
|
||||
assert rel in contents, f"unexpected per-file request: {url}"
|
||||
return FakeFileResponse(contents[rel])
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
||||
hf_calls = []
|
||||
|
||||
def fake_snapshot_download(**kwargs):
|
||||
hf_calls.append(kwargs)
|
||||
raise AssertionError("HuggingFace should not be contacted when tracker full_files are available")
|
||||
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"huggingface_hub",
|
||||
types.SimpleNamespace(snapshot_download=fake_snapshot_download),
|
||||
)
|
||||
|
||||
shard_dir = download_shard(
|
||||
"tiny-llama",
|
||||
0,
|
||||
3,
|
||||
cache_dir=tmp_path / "cache",
|
||||
hf_repo="org/tiny-llama-shards",
|
||||
model_sources=[{
|
||||
"type": "tracker-full",
|
||||
"url": "http://tracker/v1/model-files/download?model=tiny-llama&full=1",
|
||||
"files": ["config.json", "weights-a.safetensors", "weights-b.safetensors"],
|
||||
"full_files": ["config.json", "weights-a.safetensors", "weights-b.safetensors"],
|
||||
}],
|
||||
progress=False,
|
||||
)
|
||||
|
||||
assert (shard_dir / "config.json").read_text() == "{}"
|
||||
assert (shard_dir / "weights-a.safetensors").read_text() == "tracker-a"
|
||||
assert (shard_dir / "weights-b.safetensors").read_text() == "tracker-b"
|
||||
assert hf_calls == []
|
||||
|
||||
|
||||
def test_download_shard_falls_back_to_huggingface_when_tracker_source_fails(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
|
||||
@@ -13,8 +13,12 @@ import pytest
|
||||
|
||||
from meshnet_node.model_backend import (
|
||||
InsufficientVRAMError,
|
||||
PartialModelLoadUnsupported,
|
||||
TensorPayload,
|
||||
TorchModelShard,
|
||||
_call_layer,
|
||||
_load_partial_model_from_snapshot,
|
||||
_should_partial_materialize_shard,
|
||||
_decoder_attention_mask,
|
||||
_int_tensor_header,
|
||||
build_quantization_config,
|
||||
@@ -399,6 +403,295 @@ def test_call_layer_passes_rotary_position_embeddings():
|
||||
) == "hidden"
|
||||
|
||||
|
||||
def test_partial_materialize_guard_requires_local_non_full_non_quantized_snapshot(tmp_path):
|
||||
snapshot_dir = tmp_path / "snapshot"
|
||||
snapshot_dir.mkdir()
|
||||
(snapshot_dir / "config.json").write_text("{}")
|
||||
(snapshot_dir / "model.safetensors.index.json").write_text('{"weight_map": {}}')
|
||||
|
||||
assert _should_partial_materialize_shard(
|
||||
str(snapshot_dir),
|
||||
4,
|
||||
7,
|
||||
total_layers_hint=40,
|
||||
uses_quantized_weights=False,
|
||||
) is True
|
||||
assert _should_partial_materialize_shard(
|
||||
str(snapshot_dir),
|
||||
0,
|
||||
39,
|
||||
total_layers_hint=40,
|
||||
uses_quantized_weights=False,
|
||||
) is False
|
||||
assert _should_partial_materialize_shard(
|
||||
str(snapshot_dir),
|
||||
4,
|
||||
7,
|
||||
total_layers_hint=40,
|
||||
uses_quantized_weights=True,
|
||||
) is False
|
||||
assert _should_partial_materialize_shard(
|
||||
"repo/model",
|
||||
4,
|
||||
7,
|
||||
total_layers_hint=40,
|
||||
uses_quantized_weights=False,
|
||||
) is False
|
||||
|
||||
|
||||
def test_partial_snapshot_loader_materializes_only_assigned_tensors(tmp_path):
|
||||
snapshot_dir = tmp_path / "snapshot"
|
||||
snapshot_dir.mkdir()
|
||||
(snapshot_dir / "config.json").write_text("{}")
|
||||
(snapshot_dir / "model.safetensors.index.json").write_text(json.dumps({
|
||||
"weight_map": {
|
||||
"model.embed_tokens.weight": "shard-1.safetensors",
|
||||
"model.layers.0.self_attn.q_proj.weight": "shard-1.safetensors",
|
||||
"model.layers.1.self_attn.q_proj.weight": "shard-2.safetensors",
|
||||
"model.layers.2.self_attn.q_proj.weight": "shard-3.safetensors",
|
||||
"model.norm.weight": "shard-3.safetensors",
|
||||
"lm_head.weight": "shard-3.safetensors",
|
||||
}
|
||||
}))
|
||||
for rel in ("shard-1.safetensors", "shard-2.safetensors", "shard-3.safetensors"):
|
||||
(snapshot_dir / rel).write_bytes(b"stub")
|
||||
|
||||
class FakeModule:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
self.to_calls = []
|
||||
|
||||
def to(self, device):
|
||||
self.to_calls.append(device)
|
||||
return self
|
||||
|
||||
class FakeModel:
|
||||
def __init__(self):
|
||||
self.model = types.SimpleNamespace(
|
||||
embed_tokens=FakeModule("embed"),
|
||||
layers=[FakeModule("layer0"), FakeModule("layer1"), FakeModule("layer2")],
|
||||
rotary_emb=FakeModule("rotary"),
|
||||
norm=FakeModule("norm"),
|
||||
)
|
||||
self.lm_head = FakeModule("lm_head")
|
||||
self.tie_weights_called = 0
|
||||
|
||||
def tie_weights(self):
|
||||
self.tie_weights_called += 1
|
||||
|
||||
class AutoConfigStub:
|
||||
@staticmethod
|
||||
def from_pretrained(model_id):
|
||||
assert model_id == str(snapshot_dir)
|
||||
return types.SimpleNamespace(num_hidden_layers=3)
|
||||
|
||||
class AutoModelStub:
|
||||
@staticmethod
|
||||
def from_config(cfg, torch_dtype=None):
|
||||
assert cfg.num_hidden_layers == 3
|
||||
assert torch_dtype == "bf16"
|
||||
return FakeModel()
|
||||
|
||||
class EmptyWeights:
|
||||
def __init__(self):
|
||||
self.entered = 0
|
||||
self.exited = 0
|
||||
|
||||
def __call__(self):
|
||||
return self
|
||||
|
||||
def __enter__(self):
|
||||
self.entered += 1
|
||||
return None
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
self.exited += 1
|
||||
return False
|
||||
|
||||
init_empty_weights = EmptyWeights()
|
||||
set_calls = []
|
||||
|
||||
def fake_set_tensor(module, tensor_name, device, value=None, dtype=None):
|
||||
set_calls.append((tensor_name, device, value, dtype))
|
||||
|
||||
tensors = {
|
||||
"shard-1.safetensors": {
|
||||
"model.embed_tokens.weight": "embed",
|
||||
"model.layers.0.self_attn.q_proj.weight": "layer0",
|
||||
},
|
||||
"shard-2.safetensors": {
|
||||
"model.layers.1.self_attn.q_proj.weight": "layer1",
|
||||
},
|
||||
"shard-3.safetensors": {
|
||||
"model.layers.2.self_attn.q_proj.weight": "layer2",
|
||||
"model.norm.weight": "norm",
|
||||
"lm_head.weight": "lm_head",
|
||||
},
|
||||
}
|
||||
|
||||
class FakeSafeOpen:
|
||||
def __init__(self, filename, framework, device):
|
||||
assert framework == "pt"
|
||||
assert device == "cpu"
|
||||
self.filename = Path(filename).name
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def get_tensor(self, tensor_name):
|
||||
return tensors[self.filename][tensor_name]
|
||||
|
||||
model = _load_partial_model_from_snapshot(
|
||||
AutoConfigStub,
|
||||
AutoModelStub,
|
||||
types.SimpleNamespace(),
|
||||
str(snapshot_dir),
|
||||
1,
|
||||
1,
|
||||
"bf16",
|
||||
"cpu:0",
|
||||
init_empty_weights_fn=init_empty_weights,
|
||||
set_tensor_fn=fake_set_tensor,
|
||||
safe_open_fn=FakeSafeOpen,
|
||||
)
|
||||
|
||||
assert init_empty_weights.entered == 1
|
||||
assert init_empty_weights.exited == 1
|
||||
assert model.tie_weights_called == 1
|
||||
assert [call[0] for call in set_calls] == ["model.layers.1.self_attn.q_proj.weight"]
|
||||
assert model.model.layers[1].to_calls == ["cpu:0"]
|
||||
assert model.model.layers[0].to_calls == []
|
||||
assert model.model.layers[2].to_calls == []
|
||||
assert model.model.embed_tokens.to_calls == []
|
||||
assert model.model.norm.to_calls == []
|
||||
assert model.lm_head.to_calls == []
|
||||
assert model.model.rotary_emb.to_calls == ["cpu:0"]
|
||||
|
||||
|
||||
def test_partial_snapshot_loader_requires_known_layer_count(tmp_path):
|
||||
snapshot_dir = tmp_path / "snapshot"
|
||||
snapshot_dir.mkdir()
|
||||
(snapshot_dir / "config.json").write_text("{}")
|
||||
(snapshot_dir / "model.safetensors.index.json").write_text(json.dumps({
|
||||
"weight_map": {"model.layers.0.self_attn.q_proj.weight": "shard.safetensors"}
|
||||
}))
|
||||
(snapshot_dir / "shard.safetensors").write_bytes(b"stub")
|
||||
|
||||
class AutoConfigStub:
|
||||
@staticmethod
|
||||
def from_pretrained(model_id):
|
||||
return types.SimpleNamespace()
|
||||
|
||||
class AutoModelStub:
|
||||
@staticmethod
|
||||
def from_config(cfg, torch_dtype=None):
|
||||
raise AssertionError("from_config should not run without a known layer count")
|
||||
|
||||
class UnusedContext:
|
||||
def __enter__(self):
|
||||
return None
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
with pytest.raises(PartialModelLoadUnsupported, match="num_hidden_layers"):
|
||||
_load_partial_model_from_snapshot(
|
||||
AutoConfigStub,
|
||||
AutoModelStub,
|
||||
types.SimpleNamespace(),
|
||||
str(snapshot_dir),
|
||||
0,
|
||||
0,
|
||||
"bf16",
|
||||
"cpu:0",
|
||||
init_empty_weights_fn=lambda: UnusedContext(),
|
||||
set_tensor_fn=lambda *args, **kwargs: None,
|
||||
safe_open_fn=lambda *args, **kwargs: None,
|
||||
)
|
||||
|
||||
|
||||
def test_torch_model_shard_prefers_partial_loader_for_local_snapshot(tmp_path, monkeypatch):
|
||||
import meshnet_node.model_backend as backend
|
||||
|
||||
snapshot_dir = tmp_path / "snapshot"
|
||||
snapshot_dir.mkdir()
|
||||
(snapshot_dir / "config.json").write_text("{}")
|
||||
(snapshot_dir / "model.safetensors.index.json").write_text('{"weight_map": {}}')
|
||||
|
||||
class FakeModel:
|
||||
def __init__(self):
|
||||
self.model = types.SimpleNamespace(
|
||||
layers=[object(), object(), object()],
|
||||
embed_tokens=object(),
|
||||
)
|
||||
self.config = types.SimpleNamespace(hidden_size=8)
|
||||
self.eval_called = 0
|
||||
|
||||
def eval(self):
|
||||
self.eval_called += 1
|
||||
|
||||
fake_model = FakeModel()
|
||||
partial_calls = []
|
||||
|
||||
class AutoConfigStub:
|
||||
@staticmethod
|
||||
def from_pretrained(model_id, cache_dir=None):
|
||||
return types.SimpleNamespace(num_hidden_layers=3, text_config=types.SimpleNamespace(dtype="torch.bfloat16"))
|
||||
|
||||
class AutoModelStub:
|
||||
@staticmethod
|
||||
def from_pretrained(*args, **kwargs):
|
||||
raise AssertionError("full model load should not run for partial local shards")
|
||||
|
||||
class AutoTokenizerStub:
|
||||
@staticmethod
|
||||
def from_pretrained(model_id, cache_dir=None):
|
||||
assert model_id == str(snapshot_dir)
|
||||
return types.SimpleNamespace()
|
||||
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"torch",
|
||||
types.SimpleNamespace(
|
||||
cuda=types.SimpleNamespace(is_available=lambda: False),
|
||||
device=lambda value: value,
|
||||
bfloat16="bf16",
|
||||
),
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"transformers",
|
||||
types.SimpleNamespace(
|
||||
AutoConfig=AutoConfigStub,
|
||||
AutoModelForCausalLM=AutoModelStub,
|
||||
AutoTokenizer=AutoTokenizerStub,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
backend,
|
||||
"_load_partial_model_from_snapshot",
|
||||
lambda *args, **kwargs: partial_calls.append((args, kwargs)) or fake_model,
|
||||
)
|
||||
|
||||
shard = TorchModelShard(
|
||||
"repo/model",
|
||||
1,
|
||||
1,
|
||||
quantization="auto",
|
||||
cache_dir=snapshot_dir,
|
||||
)
|
||||
|
||||
assert len(partial_calls) == 1
|
||||
assert shard.model is fake_model
|
||||
assert fake_model.eval_called == 1
|
||||
assert shard.total_layers == 3
|
||||
assert shard.is_head is False
|
||||
assert shard.is_tail is False
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_two_node_gpt2_completion_is_deterministic():
|
||||
if os.environ.get("CI"):
|
||||
|
||||
Reference in New Issue
Block a user