dual billing; tracker to node model sharing

This commit is contained in:
Dobromir Popov
2026-07-06 17:31:11 +03:00
parent ccb69c41e3
commit 2e696be80f
14 changed files with 1092 additions and 41 deletions

View File

@@ -24,6 +24,18 @@ DEFAULT_BILLING_DB_PATH = "billing.sqlite"
NODE_REVENUE_SHARE = 0.90 # nodes 90% / protocol cut 10% (ADR-0015)
def _normalize_rates(value: "float | tuple[float, float] | dict") -> tuple[float, float]:
"""Coerce a price spec into an (input_per_1k, output_per_1k) pair."""
if isinstance(value, dict):
base = value.get("price")
inp = value.get("input", base)
out = value.get("output", base)
return (float(inp), float(out))
if isinstance(value, (tuple, list)):
return (float(value[0]), float(value[1]))
return (float(value), float(value))
class BillingLedger:
"""Thread-safe USDT ledger with SQLite persistence and event replication."""
@@ -33,13 +45,17 @@ class BillingLedger:
self,
db_path: str | None = None,
*,
prices: dict[str, float] | None = None,
prices: dict[str, float | tuple[float, float]] | None = None,
default_price_per_1k: float = DEFAULT_PRICE_PER_1K_TOKENS,
starting_credit: float = DEFAULT_STARTING_CREDIT,
node_share: float = NODE_REVENUE_SHARE,
) -> None:
self._db_path = db_path
self._prices = dict(prices) if prices else {}
# US-045: per-model (input_per_1k, output_per_1k). A bare float in
# ``prices`` sets both rates (legacy single-rate models).
self._prices: dict[str, tuple[float, float]] = {
model: _normalize_rates(value) for model, value in (prices or {}).items()
}
self._default_price_per_1k = default_price_per_1k
self._starting_credit = starting_credit
self._node_share = node_share
@@ -62,11 +78,23 @@ class BillingLedger:
# ---- pricing ----
def price_for(self, model: str) -> float:
return self._prices.get(model, self._default_price_per_1k)
"""Blended (average) per-1k rate — kept for estimators and history logs."""
rates = self._prices.get(model)
if rates is None:
return self._default_price_per_1k
return (rates[0] + rates[1]) / 2.0
def prices_for(self, model: str) -> tuple[float, float]:
"""(input_per_1k, output_per_1k) for a model (US-045)."""
return self._prices.get(model, (self._default_price_per_1k, self._default_price_per_1k))
def set_price(self, model: str, price_per_1k: float) -> None:
"""Legacy single-rate setter — applies the same rate to input and output."""
self.set_prices(model, price_per_1k, price_per_1k)
def set_prices(self, model: str, input_per_1k: float, output_per_1k: float) -> None:
with self._lock:
self._prices[model] = price_per_1k
self._prices[model] = (float(input_per_1k), float(output_per_1k))
# ---- local operations (create + apply + log an event) ----
@@ -130,9 +158,17 @@ class BillingLedger:
model: str,
total_tokens: int,
node_work: list[tuple[str | None, int]],
*,
input_tokens: int | None = None,
output_tokens: int | None = None,
) -> dict:
"""Debit the client and split the fee 90/10.
With ``input_tokens``/``output_tokens`` (US-045) the cost is
``input·in_rate + output·out_rate``; without them, legacy behavior —
``total_tokens`` at the blended rate. Replayed/gossiped events apply
their recorded ``cost``, so old events are unaffected either way.
``node_work`` is ``[(wallet_address | None, work_units), ...]`` for the
nodes that served the request. Work units of nodes without a wallet
accrue to the protocol cut — there is nowhere to pay them out.
@@ -140,7 +176,14 @@ class BillingLedger:
request is then rejected by ``has_funds`` (post-pay drift, standard
metered-billing behavior).
"""
cost = self.price_for(model) * max(0, total_tokens) / 1000.0
if input_tokens is not None or output_tokens is not None:
in_rate, out_rate = self.prices_for(model)
in_tokens = max(0, input_tokens or 0)
out_tokens = max(0, output_tokens or 0)
cost = (in_tokens * in_rate + out_tokens * out_rate) / 1000.0
total_tokens = in_tokens + out_tokens
else:
cost = self.price_for(model) * max(0, total_tokens) / 1000.0
total_work = sum(max(0, w) for _, w in node_work)
node_pool = cost * self._node_share
shares: dict[str, float] = {}
@@ -165,6 +208,9 @@ class BillingLedger:
"api_key": api_key,
"model": model,
"total_tokens": total_tokens,
**({"input_tokens": max(0, input_tokens or 0),
"output_tokens": max(0, output_tokens or 0)}
if (input_tokens is not None or output_tokens is not None) else {}),
"cost": cost,
"shares": shares,
"protocol_amount": protocol_amount,

View File

@@ -214,12 +214,18 @@ def main() -> None:
"enables GET /v1/pricing/hf/history)"
),
)
common.add_argument(
"--hf-pricing-refresh-interval",
type=float,
default=86400.0,
help="Seconds between dynamic pricing refresh passes (default: daily)",
)
common.add_argument(
"--hf-pricing-refresh-interval",
type=float,
default=86400.0,
help="Seconds between dynamic pricing refresh passes (default: daily)",
)
common.add_argument(
"--models-dir",
default=None,
metavar="PATH",
help="Local HuggingFace snapshot root advertised as tracker model-file source (default: MESHNET_MODELS_DIR)",
)
parser = argparse.ArgumentParser(
prog="meshnet-tracker",
@@ -276,8 +282,9 @@ def main() -> None:
args.hf_pricing_log_db
or (DEFAULT_HF_PRICING_LOG_DB_PATH if args.enable_hf_pricing else None)
),
hf_pricing_refresh_interval=args.hf_pricing_refresh_interval,
)
hf_pricing_refresh_interval=args.hf_pricing_refresh_interval,
models_dir=args.models_dir,
)
port = server.start()
print(f"meshnet-tracker listening on http://{args.host}:{port}", flush=True)
try:

View File

@@ -0,0 +1,172 @@
"""Helpers for serving layer-scoped model files from tracker-local snapshots."""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
INDEX_FILENAME = "model.safetensors.index.json"
_LAYER_RE = re.compile(
r"(?:^|\.)"
r"(?:model\.layers|layers|h|blocks|decoder\.layers|encoder\.layers)"
r"\.(\d+)(?:\.|$)"
)
_METADATA_FILENAMES = {
INDEX_FILENAME,
"config.json",
"generation_config.json",
"preprocessor_config.json",
"special_tokens_map.json",
"tokenizer.json",
"tokenizer.model",
"tokenizer_config.json",
"vocab.json",
"merges.txt",
"added_tokens.json",
}
_METADATA_PREFIXES = ("config.", "tokenizer.", "tokenizer_", "vocab.")
_HEAD_MARKERS = ("embed", "embedding", "embed_tokens", "wte", "wpe")
_TAIL_EXACT = {
"lm_head.weight",
"lm_head.bias",
"model.norm.weight",
"model.norm.bias",
"transformer.ln_f.weight",
"transformer.ln_f.bias",
"decoder.final_layer_norm.weight",
"decoder.final_layer_norm.bias",
}
_TAIL_MARKERS = (".lm_head.", ".norm.", ".ln_f.", ".final_layer_norm.")
def snapshot_dir_for_repo(models_dir: Path, repo_id: str) -> Path | None:
"""Return the most likely local HF snapshot directory for *repo_id*."""
candidates = [
models_dir / repo_id,
models_dir / repo_id.replace("/", "--"),
models_dir / f"models--{repo_id.replace('/', '--')}",
]
for candidate in candidates:
if (candidate / "snapshots").is_dir():
snapshots = sorted(p for p in (candidate / "snapshots").iterdir() if p.is_dir())
if snapshots:
return snapshots[-1]
if candidate.is_dir():
return candidate
return None
def files_for_layer_range(snapshot_dir: Path, shard_start: int, shard_end: int) -> list[str]:
"""Select files needed to load a conservative safetensors shard subset."""
return select_safetensors_files_for_layers(snapshot_dir, shard_start, shard_end)
def select_safetensors_files_for_layers(
model_dir: str | Path,
start_layer: int,
end_layer: int,
*,
total_layers: int | None = None,
) -> list[str]:
if start_layer < 0:
raise ValueError("start_layer must be non-negative")
if end_layer < start_layer:
raise ValueError("end_layer must be greater than or equal to start_layer")
root = Path(model_dir)
index_path = root / INDEX_FILENAME
try:
index = json.loads(index_path.read_text(encoding="utf-8"))
except FileNotFoundError:
return sorted(p.name for p in root.glob("*.safetensors") if p.is_file())
weight_map = index.get("weight_map")
if not isinstance(weight_map, dict):
raise ValueError(f"{INDEX_FILENAME} must contain a weight_map object")
inferred_total_layers = total_layers if total_layers is not None else _read_total_layers(root)
selected = _metadata_files(root)
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, inferred_total_layers):
selected.add(_normalise_relative_file(rel_file))
return sorted(rel for rel in selected if (root / rel).is_file())
def _tensor_belongs_to_range(
tensor_name: str,
start_layer: int,
end_layer: int,
total_layers: int | None,
) -> bool:
layer = _layer_index(tensor_name)
if layer is not None:
return start_layer <= layer <= end_layer
if start_layer == 0 and _is_head_tensor(tensor_name):
return True
if total_layers is not None and end_layer >= total_layers - 1 and _is_tail_tensor(tensor_name):
return True
return False
def _layer_index(tensor_name: str) -> int | None:
match = _LAYER_RE.search(tensor_name)
return int(match.group(1)) if match else None
def _is_head_tensor(tensor_name: str) -> bool:
lowered = tensor_name.lower()
return any(marker in lowered for marker in _HEAD_MARKERS)
def _is_tail_tensor(tensor_name: str) -> bool:
lowered = tensor_name.lower()
return lowered in _TAIL_EXACT or any(marker in lowered for marker in _TAIL_MARKERS)
def _metadata_files(root: Path) -> set[str]:
files = {INDEX_FILENAME}
for path in root.iterdir():
if not path.is_file():
continue
name = path.name
if name in _METADATA_FILENAMES or name.startswith(_METADATA_PREFIXES):
files.add(name)
return files
def _read_total_layers(root: Path) -> int | None:
config_path = root / "config.json"
if not config_path.exists():
return None
config = json.loads(config_path.read_text(encoding="utf-8"))
return _layers_from_config(config)
def _layers_from_config(config: dict[str, Any]) -> int | None:
for key in ("num_hidden_layers", "num_layers", "n_layer", "n_layers"):
value = config.get(key)
if isinstance(value, int) and value > 0:
return value
text_config = config.get("text_config")
if isinstance(text_config, dict):
return _layers_from_config(text_config)
return None
def _normalise_relative_file(rel_file: str) -> str:
path = Path(rel_file)
if path.is_absolute() or ".." in path.parts:
raise ValueError(f"unsafe relative file in {INDEX_FILENAME}: {rel_file}")
return path.as_posix()

View File

@@ -28,12 +28,14 @@ import json
import os
import socketserver
import sqlite3
import tarfile
import threading
import time
import urllib.parse
import urllib.request
import uuid
from importlib.resources import files
from pathlib import Path
from typing import Any
from .accounts import DEFAULT_ACCOUNTS_DB_PATH, AccountStore
@@ -43,6 +45,7 @@ from .billing import DEFAULT_BILLING_DB_PATH, BillingLedger
from .calibration import DEFAULT_CALIBRATION_DB_PATH, ToplocCalibrationStore
from .hf_pricing import DEFAULT_HF_PRICING_LOG_DB_PATH, HfPricingLog, refresh_preset_price
from .gossip import NodeGossip
from .model_files import files_for_layer_range, snapshot_dir_for_repo
from .raft import RaftNode
@@ -118,6 +121,14 @@ def _clone_model_presets(presets: dict[str, dict]) -> dict[str, dict]:
DEFAULT_VRAM_BYTES = 8 * 1024 * 1024 * 1024
DEFAULT_RAM_BYTES = 16 * 1024 * 1024 * 1024
def _snapshot_regular_files(snapshot_dir: Path) -> list[str]:
return sorted(
path.relative_to(snapshot_dir).as_posix()
for path in snapshot_dir.rglob("*")
if path.is_file()
)
DEFAULT_QUANTIZATIONS = ["bfloat16"]
DEFAULT_BENCHMARK_TOKENS_PER_SEC = 1.0
# US-039/US-040 — single source of truth for the credit defaults (referenced by
@@ -1008,8 +1019,25 @@ def _relay_http_request(
}
def _stream_line_tokens(line: bytes) -> tuple[int, int | None]:
"""Token accounting for one SSE line: (observed delta, reported total or None)."""
def _usage_split(payload: dict) -> dict | None:
"""Parse a usage block into {"prompt", "completion", "total"} (ints or None)."""
usage = payload.get("usage")
if not isinstance(usage, dict):
return None
def _num(key: str) -> int | None:
value = usage.get(key)
return int(value) if isinstance(value, (int, float)) else None
return {
"prompt": _num("prompt_tokens"),
"completion": _num("completion_tokens"),
"total": _usage_total_tokens(payload),
}
def _stream_line_tokens(line: bytes) -> tuple[int, dict | None]:
"""Token accounting for one SSE line: (observed output delta, usage split or None)."""
if not line.startswith(b"data:"):
return 0, None
payload = line[5:].strip()
@@ -1019,7 +1047,49 @@ def _stream_line_tokens(line: bytes) -> tuple[int, int | None]:
chunk_payload = json.loads(payload)
except json.JSONDecodeError:
return 1, None
return _observed_stream_tokens(chunk_payload), _usage_total_tokens(chunk_payload)
return _observed_stream_tokens(chunk_payload), _usage_split(chunk_payload)
def _stream_billable_split(
observed_output: int, usage: dict | None, request_body: dict
) -> tuple[int, int]:
"""(input_tokens, output_tokens) for a streamed response (US-045).
Output: observed deltas, capped by reported completion count when the
stream carried a usage chunk. Input: reported prompt count, else the
prompt estimate from the request body.
"""
prompt = (usage or {}).get("prompt")
completion = (usage or {}).get("completion")
total = (usage or {}).get("total")
if prompt is None:
prompt = _estimate_prompt_tokens(request_body) or 0
if completion is None and total is not None:
completion = max(0, total - prompt)
return max(0, prompt), _billable_stream_tokens(observed_output, completion)
def _billable_non_stream_split(payload: dict, request_body: dict) -> tuple[int, int]:
"""(input_tokens, output_tokens) for a buffered response (US-045).
Prefers the response usage block; falls back to content estimates.
Completion stays capped by the request's max-tokens bound, as before.
"""
usage = _usage_split(payload)
prompt = (usage or {}).get("prompt")
completion = (usage or {}).get("completion")
if prompt is None:
prompt = _estimate_prompt_tokens(request_body) or 0
if completion is None:
total = (usage or {}).get("total")
if total is not None:
completion = max(0, total - prompt)
else:
completion = _observed_non_stream_completion_tokens(payload)
limit = _requested_completion_token_limit(request_body)
if limit is not None:
completion = min(completion, limit)
return max(0, prompt), max(0, completion)
def _find_pinned_route(
@@ -1598,6 +1668,7 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
toploc_calibration_gate_min_hardware_profiles: int = 1,
toploc_backend: Any | None = None,
hf_pricing_log: "HfPricingLog | None" = None,
models_dir: Path | None = None,
) -> None:
super().__init__(addr, handler)
self.registry = registry
@@ -1628,6 +1699,7 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
self.toploc_calibration_gate_min_hardware_profiles = toploc_calibration_gate_min_hardware_profiles
self.toploc_backend = toploc_backend
self.hf_pricing_log: HfPricingLog | None = hf_pricing_log
self.models_dir = models_dir
class _TrackerHandler(http.server.BaseHTTPRequestHandler):
@@ -1800,6 +1872,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
self._handle_network_map()
elif parsed.path == "/v1/models":
self._handle_models()
elif parsed.path == "/v1/model-files/download":
self._handle_model_files_download(parsed)
elif parsed.path.startswith("/v1/coverage/"):
model = urllib.parse.unquote(parsed.path.removeprefix("/v1/coverage/"))
self._handle_coverage(model)
@@ -2482,8 +2556,16 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
model: str,
total_tokens: int,
node_work: list[tuple[str | None, int]],
*,
input_tokens: int | None = None,
output_tokens: int | None = None,
) -> None:
"""Charge a completed request against the billing ledger (ADR-0015)."""
"""Charge a completed request against the billing ledger (ADR-0015).
With ``input_tokens``/``output_tokens`` the ledger bills each side at
its own rate (US-045); ``total_tokens`` alone falls back to the
blended rate.
"""
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
if server.billing is None or api_key is None:
return
@@ -2501,11 +2583,15 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
adjusted.append((wallet, work))
node_work = adjusted
try:
event = server.billing.charge_request(api_key, model, total_tokens, node_work)
event = server.billing.charge_request(
api_key, model, total_tokens, node_work,
input_tokens=input_tokens, output_tokens=output_tokens,
)
print(
f"[tracker] billed api_key=…{api_key[-6:]}: model={model!r} "
f"tokens={total_tokens} cost={event['cost']:.6f} USDT "
f"shares={event['shares']}",
f"tokens={event['total_tokens']} "
f"(in={event.get('input_tokens', '?')} out={event.get('output_tokens', '?')}) "
f"cost={event['cost']:.6f} USDT shares={event['shares']}",
flush=True,
)
except Exception as exc:
@@ -3699,9 +3785,83 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
"model": resolved_name,
"model_layers_end": required_end,
"peers": peers,
"model_sources": self._model_sources(
resolved_name,
preset,
shard_start,
shard_end,
),
**({"hf_repo": preset["hf_repo"]} if "hf_repo" in preset else {}),
})
def _model_sources(self, model: str, preset: dict, shard_start: int, shard_end: int) -> list[dict]:
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
hf_repo = preset.get("hf_repo")
if not server.models_dir or not isinstance(hf_repo, str) or not hf_repo:
return []
snapshot_dir = snapshot_dir_for_repo(server.models_dir, hf_repo)
if snapshot_dir is None:
return []
files = files_for_layer_range(snapshot_dir, shard_start, shard_end)
if not files:
return []
host = self.headers.get("Host") or f"{self.server.server_address[0]}:{self.server.server_address[1]}"
base_url = f"http://{host}"
query = urllib.parse.urlencode({
"model": model,
"shard_start": shard_start,
"shard_end": shard_end,
})
full_query = urllib.parse.urlencode({"model": model, "full": 1})
return [{
"type": "tracker",
"endpoint": base_url,
"url": f"{base_url}/v1/model-files/download?{query}",
"full_url": f"{base_url}/v1/model-files/download?{full_query}",
"files": files,
}]
def _handle_model_files_download(self, parsed: urllib.parse.ParseResult) -> None:
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
if server.models_dir is None:
self._send_json(404, {"error": "tracker model-file source is not enabled"})
return
params = urllib.parse.parse_qs(parsed.query)
model = params.get("model", [""])[0]
resolved_name, preset = _resolve_model_preset(server.model_presets, model)
if preset is None:
self._send_json(404, {"error": f"unknown model preset: {model!r}"})
return
hf_repo = preset.get("hf_repo")
if not isinstance(hf_repo, str) or not hf_repo:
self._send_json(404, {"error": f"model preset has no hf_repo: {resolved_name!r}"})
return
snapshot_dir = snapshot_dir_for_repo(server.models_dir, hf_repo)
if snapshot_dir is None:
self._send_json(404, {"error": f"local snapshot not found for {hf_repo}"})
return
full_download = params.get("full", ["0"])[0] in {"1", "true", "yes"}
if full_download:
rel_files = _snapshot_regular_files(snapshot_dir)
else:
try:
shard_start = int(params.get("shard_start", [""])[0])
shard_end = int(params.get("shard_end", [""])[0])
except ValueError:
self._send_json(400, {"error": "shard_start and shard_end must be integers"})
return
rel_files = files_for_layer_range(snapshot_dir, shard_start, shard_end)
if not rel_files:
self._send_json(404, {"error": "no local files matched the assigned shard"})
return
self.send_response(200)
self.send_header("Content-Type", "application/x-tar")
self.send_header("X-Meshnet-Model-Source", "tracker")
self.end_headers()
with tarfile.open(fileobj=self.wfile, mode="w|") as archive:
for rel in rel_files:
archive.add(snapshot_dir / rel, arcname=rel)
def _handle_network_assign(self, parsed: urllib.parse.ParseResult):
"""Assign a new node to fill the biggest uncovered shard gap across HF-model nodes.
@@ -3771,6 +3931,12 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
"gap_found": True,
"price_per_token": 0.0,
"deployment": _deployment_summary(all_nodes, preset),
"model_sources": self._model_sources(
str(resolved_name),
preset,
shard_start,
shard_end,
),
})
return
@@ -3842,7 +4008,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
# Capacity: use the same 80%-of-memory rule as registered node planning.
total_l = best_num_layers
memory_mb = vram_mb if vram_mb > 0 else ram_mb
_resolved_name, best_preset = _resolve_model_preset(server.model_presets, str(best_repo))
resolved_name, best_preset = _resolve_model_preset(server.model_presets, str(best_repo))
if memory_mb > 0:
max_layers = _max_layers_for_memory(memory_mb, total_l, best_preset)
elif device == "cuda" and vram_mb >= 8192:
@@ -3855,11 +4021,18 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
self._send_json(200, {
"hf_repo": best_repo,
"model": resolved_name,
"shard_start": shard_start,
"shard_end": shard_end,
"num_layers": total_l,
"gap_found": gap_found,
"price_per_token": 0.0,
"model_sources": self._model_sources(
str(resolved_name or best_repo),
best_preset or {"hf_repo": best_repo},
shard_start,
shard_end,
),
})
def _handle_route(self, parsed: urllib.parse.ParseResult):
@@ -4053,6 +4226,7 @@ class TrackerServer:
hf_pricing_log_db: str | None = None,
hf_pricing_refresh_interval: float = 86400.0,
hf_pricing_fetch_html: Any | None = None,
models_dir: str | Path | None = None,
) -> None:
self._host = host
self._requested_port = port
@@ -4140,6 +4314,8 @@ class TrackerServer:
self._enable_hf_pricing = enable_hf_pricing
self._hf_pricing_refresh_interval = hf_pricing_refresh_interval
self._hf_pricing_fetch_html = hf_pricing_fetch_html
raw_models_dir = models_dir if models_dir is not None else os.environ.get("MESHNET_MODELS_DIR")
self._models_dir = Path(raw_models_dir).expanduser() if raw_models_dir else None
self._hf_pricing_stop = threading.Event()
self._hf_pricing_thread: threading.Thread | None = None
self.port: int | None = None
@@ -4178,6 +4354,7 @@ class TrackerServer:
toploc_calibration_gate_min_hardware_profiles=self._toploc_calibration_gate_min_hardware_profiles,
toploc_backend=self._toploc_backend,
hf_pricing_log=self._hf_pricing_log,
models_dir=self._models_dir,
)
self.port = self._server.server_address[1]