Normalize line endings to LF via .gitattributes

Adds a committed .gitattributes so Windows and Linux checkouts converge
on LF for all text files, overriding each developer's local core.autocrlf.
Renormalizes existing blobs (server.py, dashboard.html, etc.) that had
CRLF baked in, clearing the repo-wide phantom "modified" churn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dobromir Popov
2026-07-08 16:15:32 +02:00
parent 9c73db0ef2
commit 560de08edd
13 changed files with 6705 additions and 6677 deletions

28
.gitattributes vendored Normal file
View File

@@ -0,0 +1,28 @@
# Normalize line endings across Windows/Linux checkouts.
# All text files are stored as LF in the repo and checked out as LF
# on every OS. Git auto-detects text vs binary.
* text=auto eol=lf
# Explicitly binary — never touch these bytes.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.pdf binary
*.zip binary
*.gz binary
*.tar binary
*.wasm binary
*.sqlite binary
*.sqlite3 binary
*.safetensors binary
*.gguf binary
# Scripts that must stay LF even if someone forces CRLF locally.
*.sh text eol=lf
*.py text eol=lf
# Windows batch files genuinely need CRLF.
*.bat text eol=crlf
*.cmd text eol=crlf

View File

@@ -1,23 +1,23 @@
# Distributed Inference Network # Distributed Inference Network
A volunteer GPU network where nodes independently load model shards, a tracker routes inference through optimal node chains, and contributors earn tokens for serving compute. A volunteer GPU network where nodes independently load model shards, a tracker routes inference through optimal node chains, and contributors earn tokens for serving compute.
## Language ## Language
### Nodes & compute ### Nodes & compute
**Node**: **Node**:
A volunteer machine that runs the node client, holds one or more shards on disk, and serves inference requests for those shards. A volunteer machine that runs the node client, holds one or more shards on disk, and serves inference requests for those shards.
_Avoid_: worker, peer, miner, server _Avoid_: worker, peer, miner, server
**Shard**: **Shard**:
A contiguous range of transformer layers from a model that a node loads and serves. Shards are the unit of storage, assignment, and reward. A contiguous range of transformer layers from a model that a node loads and serves. Shards are the unit of storage, assignment, and reward.
_Avoid_: partition, slice, chunk, segment _Avoid_: partition, slice, chunk, segment
**Shard Swarm**: **Shard Swarm**:
The P2P group of nodes that collectively seed and download a specific shard. One swarm exists per shard. The P2P group of nodes that collectively seed and download a specific shard. One swarm exists per shard.
_Avoid_: torrent, cluster, pool _Avoid_: torrent, cluster, pool
**Inference Route**: **Inference Route**:
An ordered sequence of nodes whose shards together cover all layers of a model. The tracker selects the optimal route per request. An ordered sequence of nodes whose shards together cover all layers of a model. The tracker selects the optimal route per request.
_Avoid_: pipeline, chain, path _Avoid_: pipeline, chain, path
@@ -55,115 +55,115 @@ Realtime progress information for an active Route Session, including phase, gene
_Avoid_: logs, debug output _Avoid_: logs, debug output
### Tracker ### Tracker
**Tracker**: **Tracker**:
The coordinator service that maintains the node registry, scores nodes by throughput/latency, and assigns inference routes. Runs as a centralized service with a P2P gossip fallback. The coordinator service that maintains the node registry, scores nodes by throughput/latency, and assigns inference routes. Runs as a centralized service with a P2P gossip fallback.
_Avoid_: coordinator, scheduler, director _Avoid_: coordinator, scheduler, director
**Tracker Node**: **Tracker Node**:
A node that serves at least the first-layer shard (`layers[0..k]`) for a model and acts as the inference entry point for that model. Tracker nodes own the tokenizer and `embed_tokens`, receive client requests directly, select the onward route from the coverage map, and stream results and progress when possible. Any node advertising a new model to the network becomes its tracker node. A node that serves at least the first-layer shard (`layers[0..k]`) for a model and acts as the inference entry point for that model. Tracker nodes own the tokenizer and `embed_tokens`, receive client requests directly, select the onward route from the coverage map, and stream results and progress when possible. Any node advertising a new model to the network becomes its tracker node.
_Avoid_: primary node, master node, gateway node _Avoid_: primary node, master node, gateway node
**Coverage Map**: **Coverage Map**:
The tracker's per-model mapping of layer ranges to node counts: `[(start_layer, end_layer, node_count), ...]`. A layer range with `node_count=0` is a coverage gap — the model is unroutable until the gap is filled. Coverage-first bin-packing fills all gaps before adding redundancy. The tracker's per-model mapping of layer ranges to node counts: `[(start_layer, end_layer, node_count), ...]`. A layer range with `node_count=0` is a coverage gap — the model is unroutable until the gap is filled. Coverage-first bin-packing fills all gaps before adding redundancy.
_Avoid_: shard map, assignment table, coverage report _Avoid_: shard map, assignment table, coverage report
**Rebalance Directive**: **Rebalance Directive**:
A `LOAD_SHARD` or `DROP_SHARD` instruction the tracker issues to a node when the coverage map changes (node joins, node leaves, or load-balance reoptimization). Delivered as part of the node's heartbeat response. A `LOAD_SHARD` or `DROP_SHARD` instruction the tracker issues to a node when the coverage map changes (node joins, node leaves, or load-balance reoptimization). Delivered as part of the node's heartbeat response.
_Avoid_: rebalance command, shard instruction, migration order _Avoid_: rebalance command, shard instruction, migration order
**Node Score**: **Node Score**:
A throughput/latency rating the tracker maintains per node, used for route selection. Updated continuously from inference telemetry. A throughput/latency rating the tracker maintains per node, used for route selection. Updated continuously from inference telemetry.
_Avoid_: reputation, rating, rank _Avoid_: reputation, rating, rank
### Payments & fraud ### Payments & fraud
**Stake**: **Stake**:
Collateral a node stands to lose for fraud. In the current design the node's Pending Balance serves as stake — no upfront deposit is required. An optional USDT/TAI deposit may return later for routing priority. Collateral a node stands to lose for fraud. In the current design the node's Pending Balance serves as stake — no upfront deposit is required. An optional USDT/TAI deposit may return later for routing priority.
_Avoid_: deposit, bond, escrow _Avoid_: deposit, bond, escrow
**Treasury**: **Treasury**:
The single project-owned Solana wallet that custodially holds client deposits, pays node payouts, and accumulates the Protocol Cut. Its keypair is loaded only on settlement-capable trackers. The single project-owned Solana wallet that custodially holds client deposits, pays node payouts, and accumulates the Protocol Cut. Its keypair is loaded only on settlement-capable trackers.
_Avoid_: escrow, vault, hot wallet _Avoid_: escrow, vault, hot wallet
**Pending Balance**: **Pending Balance**:
A node's accrued, not-yet-paid USDT earnings on the tracker ledger. Doubles as the node's fraud collateral: it is forfeited in full when a validator catches a divergent output. A node's accrued, not-yet-paid USDT earnings on the tracker ledger. Doubles as the node's fraud collateral: it is forfeited in full when a validator catches a divergent output.
_Avoid_: unpaid rewards, accrual, balance due _Avoid_: unpaid rewards, accrual, balance due
**Settlement Period**: **Settlement Period**:
The dynamic interval driving on-chain payouts: a node is paid when its Pending Balance exceeds the Payout Threshold or the period elapses, whichever comes first. Short in development (seconds), long in production (daily), configurable to grow with volume. The dynamic interval driving on-chain payouts: a node is paid when its Pending Balance exceeds the Payout Threshold or the period elapses, whichever comes first. Short in development (seconds), long in production (daily), configurable to grow with volume.
_Avoid_: epoch, payout cycle, billing cycle _Avoid_: epoch, payout cycle, billing cycle
**Payout Threshold**: **Payout Threshold**:
The minimum Pending Balance that triggers an immediate payout before the Settlement Period elapses. Includes a dust floor so payouts are never smaller than they are worth. The minimum Pending Balance that triggers an immediate payout before the Settlement Period elapses. Includes a dust floor so payouts are never smaller than they are worth.
_Avoid_: minimum payout, dust limit _Avoid_: minimum payout, dust limit
**Protocol Cut**: **Protocol Cut**:
The 10% of inference fees retained by the project for infrastructure; the remaining 90% goes to the nodes that served the request. Accumulates in the Treasury as the future TAI liquidity reserve. The 10% of inference fees retained by the project for infrastructure; the remaining 90% goes to the nodes that served the request. Accumulates in the Treasury as the future TAI liquidity reserve.
_Avoid_: spread, commission, house fee _Avoid_: spread, commission, house fee
**Deposit Watcher**: **Deposit Watcher**:
The tracker component that observes the Treasury's on-chain USDT deposits and credits the sending client's API-key ledger balance. The tracker component that observes the Treasury's on-chain USDT deposits and credits the sending client's API-key ledger balance.
_Avoid_: payment listener, chain scanner _Avoid_: payment listener, chain scanner
**Mock USDT**: **Mock USDT**:
The self-created 6-decimal SPL mint that stands in for USDT on devnet, where real USDT does not exist. The mint address is configuration, so mainnet cutover is a config change. The self-created 6-decimal SPL mint that stands in for USDT on devnet, where real USDT does not exist. The mint address is configuration, so mainnet cutover is a config change.
_Avoid_: test token, fake USDT, devnet dollar _Avoid_: test token, fake USDT, devnet dollar
**Tax**: **Tax**:
The share of caller payments distributed to compute nodes as rewards. Taxes are weighted by completed work and historical node speed so faster, larger nodes earn proportionally more. The share of caller payments distributed to compute nodes as rewards. Taxes are weighted by completed work and historical node speed so faster, larger nodes earn proportionally more.
_Avoid_: fee, toll, commission _Avoid_: fee, toll, commission
**Caller Credit**: **Caller Credit**:
Free starting balance granted to a new caller/API key so they can try the network before topping up. Free starting balance granted to a new caller/API key so they can try the network before topping up.
_Avoid_: signup bonus, faucet, airdrop _Avoid_: signup bonus, faucet, airdrop
**Free Compute Job**: **Free Compute Job**:
Work a compute node performs without earning immediate rewards, usually during probation or bootstrap phases. Work a compute node performs without earning immediate rewards, usually during probation or bootstrap phases.
_Avoid_: unpaid labor, warmup request _Avoid_: unpaid labor, warmup request
**Slash**: **Slash**:
The penalty for a proven fraud incident: the node's entire Pending Balance is forfeited to the Treasury and a Strike is recorded. The penalty for a proven fraud incident: the node's entire Pending Balance is forfeited to the Treasury and a Strike is recorded.
_Avoid_: penalize, burn, fine, forfeit _Avoid_: penalize, burn, fine, forfeit
**Strike**: **Strike**:
A fraud incident recorded on-chain against a node. Enough strikes result in a ban. A fraud incident recorded on-chain against a node. Enough strikes result in a ban.
_Avoid_: infraction, violation, flag _Avoid_: infraction, violation, flag
**Ban**: **Ban**:
Permanent exclusion of a wallet from the network after exceeding the strike threshold. Recorded on-chain. Permanent exclusion of a wallet from the network after exceeding the strike threshold. Recorded on-chain.
_Avoid_: blacklist, block, suspension _Avoid_: blacklist, block, suspension
**Probationary Period**: **Probationary Period**:
The first N jobs a new wallet must complete without earning, to raise the cost of re-entering after a ban. The first N jobs a new wallet must complete without earning, to raise the cost of re-entering after a ban.
_Avoid_: trial period, warmup, grace period _Avoid_: trial period, warmup, grace period
**Token**: **Token**:
TAI, our native Solana SPL token. Deferred (ADR-0015): nodes are currently paid directly in USDT; TAI returns as the reward/upside layer once volume exists, funded by the accumulated Protocol Cut. Clients never need to hold it. TAI, our native Solana SPL token. Deferred (ADR-0015): nodes are currently paid directly in USDT; TAI returns as the reward/upside layer once volume exists, funded by the accumulated Protocol Cut. Clients never need to hold it.
_Avoid_: coin, reward token, native token _Avoid_: coin, reward token, native token
**Contract Boundary**: **Contract Boundary**:
The Python interface in `packages/contracts` that represents registry, payment, and settlement behavior. During the prototype it is implemented by deterministic local wrappers; later the same boundary is backed by real Solana programs. The Python interface in `packages/contracts` that represents registry, payment, and settlement behavior. During the prototype it is implemented by deterministic local wrappers; later the same boundary is backed by real Solana programs.
_Avoid_: mock contract, fake chain, temporary hack _Avoid_: mock contract, fake chain, temporary hack
**Validator**: **Validator**:
A trusted node (or the tracker itself) that re-runs a sample of inference requests to detect fraud. A trusted node (or the tracker itself) that re-runs a sample of inference requests to detect fraud.
_Avoid_: auditor, checker, referee _Avoid_: auditor, checker, referee
**Validation Event**: **Validation Event**:
A completed inference record that contains enough information for a validator to decide whether to sample and re-run the request: session id, model preset, messages, inference route, node wallets, and observed output. A completed inference record that contains enough information for a validator to decide whether to sample and re-run the request: session id, model preset, messages, inference route, node wallets, and observed output.
_Avoid_: audit log, trace, receipt _Avoid_: audit log, trace, receipt
**Slash Proof**: **Slash Proof**:
The record submitted by a validator when a sampled re-run diverges from the observed output beyond tolerance. In the prototype this is deterministic local contract state; later it maps to an on-chain proof transaction. The record submitted by a validator when a sampled re-run diverges from the observed output beyond tolerance. In the prototype this is deterministic local contract state; later it maps to an on-chain proof transaction.
_Avoid_: accusation, report, claim _Avoid_: accusation, report, claim
### Client-facing ### Client-facing
**Client**: **Client**:
Any application or user that sends inference requests to the gateway. Prepays USDT into the Treasury; each request is metered against the resulting ledger balance at a per-1K-tokens price set per model. Any application or user that sends inference requests to the gateway. Prepays USDT into the Treasury; each request is metered against the resulting ledger balance at a per-1K-tokens price set per model.
_Avoid_: user, caller, consumer _Avoid_: user, caller, consumer
**Model Preset**: **Model Preset**:
A named, versioned model available on the network (e.g. `llama-3-70b`). The tracker knows which nodes hold which shards for each preset. A named, versioned model available on the network (e.g. `llama-3-70b`). The tracker knows which nodes hold which shards for each preset.
_Avoid_: model, checkpoint, version _Avoid_: model, checkpoint, version

File diff suppressed because it is too large Load Diff

View File

@@ -1,111 +1,111 @@
# Installing meshnet-node on Windows 11 with WSL2 # Installing meshnet-node on Windows 11 with WSL2
This guide covers setting up a meshnet-node on a Windows 11 machine using WSL2 with CUDA passthrough so it can join an existing inference network over LAN. This guide covers setting up a meshnet-node on a Windows 11 machine using WSL2 with CUDA passthrough so it can join an existing inference network over LAN.
## Prerequisites ## Prerequisites
- Windows 11 with WSL2 support (most systems with Windows 10 version 2004+ qualify) - Windows 11 with WSL2 support (most systems with Windows 10 version 2004+ qualify)
- NVIDIA GPU with CUDA support (driver ≥ 525.x recommended for WSL2 CUDA) - NVIDIA GPU with CUDA support (driver ≥ 525.x recommended for WSL2 CUDA)
- At least 8 GB RAM + enough VRAM for the model shard you intend to serve - At least 8 GB RAM + enough VRAM for the model shard you intend to serve
- The Linux machine (other node) is reachable on your LAN - The Linux machine (other node) is reachable on your LAN
--- ---
## Step 1 — Enable WSL2 and install Ubuntu ## Step 1 — Enable WSL2 and install Ubuntu
Open **PowerShell as Administrator** and run: Open **PowerShell as Administrator** and run:
```powershell ```powershell
wsl --install -d Ubuntu-24.04 wsl --install -d Ubuntu-24.04
``` ```
This installs WSL2 with Ubuntu 24.04. Reboot when prompted. This installs WSL2 with Ubuntu 24.04. Reboot when prompted.
After reboot, Ubuntu starts and asks you to create a UNIX username/password. Choose anything convenient. After reboot, Ubuntu starts and asks you to create a UNIX username/password. Choose anything convenient.
Verify WSL version: Verify WSL version:
```powershell ```powershell
wsl -l -v wsl -l -v
``` ```
Output should show `VERSION 2`. Output should show `VERSION 2`.
--- ---
## Step 2 — Install NVIDIA GPU driver on Windows (NOT inside WSL) ## Step 2 — Install NVIDIA GPU driver on Windows (NOT inside WSL)
WSL2 CUDA passthrough works through the Windows host driver. **Do not install CUDA inside WSL2.** WSL2 CUDA passthrough works through the Windows host driver. **Do not install CUDA inside WSL2.**
1. Download the latest Game Ready or Studio driver for your GPU from https://www.nvidia.com/drivers 1. Download the latest Game Ready or Studio driver for your GPU from https://www.nvidia.com/drivers
2. Install on Windows normally (standard installer). 2. Install on Windows normally (standard installer).
3. Inside WSL2 (Ubuntu terminal), verify: 3. Inside WSL2 (Ubuntu terminal), verify:
```bash ```bash
nvidia-smi nvidia-smi
``` ```
Expected output: your GPU name, driver version, CUDA version. If this command fails, the Windows driver is too old — update it. Expected output: your GPU name, driver version, CUDA version. If this command fails, the Windows driver is too old — update it.
> **Note:** The `cuda-toolkit` package inside WSL2 is optional and only needed if you compile CUDA kernels. For inference with `torch`, the Windows host driver is sufficient. > **Note:** The `cuda-toolkit` package inside WSL2 is optional and only needed if you compile CUDA kernels. For inference with `torch`, the Windows host driver is sufficient.
--- ---
## Step 3 — Install Python 3.11+ inside WSL2 ## Step 3 — Install Python 3.11+ inside WSL2
Ubuntu 24.04 ships Python 3.12. Confirm: Ubuntu 24.04 ships Python 3.12. Confirm:
```bash ```bash
python3 --version python3 --version
``` ```
If it shows 3.10 or older: If it shows 3.10 or older:
```bash ```bash
sudo add-apt-repository ppa:deadsnakes/ppa sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt update sudo apt update
sudo apt install python3.12 python3.12-venv python3.12-dev sudo apt install python3.12 python3.12-venv python3.12-dev
``` ```
Install pip: Install pip:
```bash ```bash
curl -sS https://bootstrap.pypa.io/get-pip.py | python3 curl -sS https://bootstrap.pypa.io/get-pip.py | python3
``` ```
--- ---
## Step 4 — Clone the repository ## Step 4 — Clone the repository
Inside WSL2: Inside WSL2:
```bash ```bash
# Store the repo in the Linux filesystem (faster I/O than /mnt/c) # Store the repo in the Linux filesystem (faster I/O than /mnt/c)
cd ~ cd ~
git clone https://github.com/YOUR_ORG/d-popov.com.git git clone https://github.com/YOUR_ORG/d-popov.com.git
cd d-popov.com/AI cd d-popov.com/AI
``` ```
--- ---
## Step 5 — Create a virtualenv and install meshnet-node ## Step 5 — Create a virtualenv and install meshnet-node
```bash ```bash
python3 -m venv .venv python3 -m venv .venv
source .venv/bin/activate source .venv/bin/activate
# Install node + PyTorch (CUDA build) # Install node + PyTorch (CUDA build)
pip install torch --index-url https://download.pytorch.org/whl/cu124 pip install torch --index-url https://download.pytorch.org/whl/cu124
pip install -e "packages/node[torch]" pip install -e "packages/node[torch]"
``` ```
Verify the install: Verify the install:
```bash ```bash
meshnet-node --help meshnet-node --help
python -c "import transformers; print(transformers.__version__)" python -c "import transformers; print(transformers.__version__)"
``` ```
`transformers` must be **≥ 5.12** for Qwen3.5/3.6-MoE models (older versions fail `transformers` must be **≥ 5.12** for Qwen3.5/3.6-MoE models (older versions fail
with `'Qwen3_5MoeConfig' object has no attribute 'vocab_size'`). If you install with `'Qwen3_5MoeConfig' object has no attribute 'vocab_size'`). If you install
into an existing conda/miniforge env instead of a fresh venv, run into an existing conda/miniforge env instead of a fresh venv, run
@@ -124,107 +124,107 @@ Without it, Qwen3.5/3.6-MoE startup can fail with the misleading message
`Could not import module 'Qwen3_5MoeForCausalLM'`. `Could not import module 'Qwen3_5MoeForCausalLM'`.
--- ---
## Step 6 — Pre-download the model shard ## Step 6 — Pre-download the model shard
Download the model before starting the node so the startup process doesn't time out on the tracker side: Download the model before starting the node so the startup process doesn't time out on the tracker side:
```bash ```bash
python3 - <<'EOF' python3 - <<'EOF'
from transformers import AutoConfig from transformers import AutoConfig
AutoConfig.from_pretrained("microsoft/Phi-3-medium-128k-instruct") AutoConfig.from_pretrained("microsoft/Phi-3-medium-128k-instruct")
EOF EOF
``` ```
For the full model weights (needed at runtime), `transformers` downloads them automatically on first `meshnet-node` start. If you want to pre-fetch: For the full model weights (needed at runtime), `transformers` downloads them automatically on first `meshnet-node` start. If you want to pre-fetch:
```bash ```bash
python3 -c " python3 -c "
from transformers import AutoModelForCausalLM from transformers import AutoModelForCausalLM
AutoModelForCausalLM.from_pretrained('microsoft/Phi-3-medium-128k-instruct', device_map='cpu') AutoModelForCausalLM.from_pretrained('microsoft/Phi-3-medium-128k-instruct', device_map='cpu')
" "
``` ```
This can take 1030 minutes on first run. This can take 1030 minutes on first run.
--- ---
## Step 7 — Expose the node port to your LAN ## Step 7 — Expose the node port to your LAN
WSL2 runs behind a NAT with a virtual IP (typically `172.x.x.x`). Your LAN sees the Windows host IP. You need to forward the node port. WSL2 runs behind a NAT with a virtual IP (typically `172.x.x.x`). Your LAN sees the Windows host IP. You need to forward the node port.
**Option A — Windows port proxy (recommended for simple setups):** **Option A — Windows port proxy (recommended for simple setups):**
In **PowerShell as Administrator**: In **PowerShell as Administrator**:
```powershell ```powershell
# Get the current WSL2 IP (changes on each WSL restart) # Get the current WSL2 IP (changes on each WSL restart)
$wslIp = (wsl hostname -I).Trim() $wslIp = (wsl hostname -I).Trim()
# Forward Windows host port 8001 → WSL2 port 8001 # Forward Windows host port 8001 → WSL2 port 8001
netsh interface portproxy add v4tov4 ` netsh interface portproxy add v4tov4 `
listenport=8001 listenaddress=0.0.0.0 ` listenport=8001 listenaddress=0.0.0.0 `
connectport=8001 connectaddress=$wslIp connectport=8001 connectaddress=$wslIp
# Allow inbound on Windows Firewall # Allow inbound on Windows Firewall
New-NetFirewallRule -DisplayName "meshnet-node" ` New-NetFirewallRule -DisplayName "meshnet-node" `
-Direction Inbound -Protocol TCP -LocalPort 8001 -Action Allow -Direction Inbound -Protocol TCP -LocalPort 8001 -Action Allow
``` ```
Verify: from the Linux machine, `curl http://WINDOWS_LAN_IP:8001/v1/health` should return a response once the node is running. Verify: from the Linux machine, `curl http://WINDOWS_LAN_IP:8001/v1/health` should return a response once the node is running.
**Redo this after every WSL2 restart** — the WSL2 IP changes. **Redo this after every WSL2 restart** — the WSL2 IP changes.
**Option B — P2P relay (US-017, no port forwarding needed):** **Option B — P2P relay (US-017, no port forwarding needed):**
Start a relay node on the Linux machine. The WSL2 node connects outbound through the relay. No firewall rules needed. See `docs/TWO_MACHINE_TEST.md` for details. Start a relay node on the Linux machine. The WSL2 node connects outbound through the relay. No firewall rules needed. See `docs/TWO_MACHINE_TEST.md` for details.
--- ---
## Step 8 — Start the node ## Step 8 — Start the node
Replace `192.168.1.10` with the actual LAN IP of the Linux machine running the tracker. Replace `192.168.1.10` with the actual LAN IP of the Linux machine running the tracker.
Replace shard range with the complementary range to what the Linux node is serving. Replace shard range with the complementary range to what the Linux node is serving.
```bash ```bash
source .venv/bin/activate source .venv/bin/activate
meshnet-node \ meshnet-node \
--model microsoft/Phi-3-medium-128k-instruct \ --model microsoft/Phi-3-medium-128k-instruct \
--quantization bf16 \ --quantization bf16 \
--shard-start 20 --shard-end 39 \ --shard-start 20 --shard-end 39 \
--tracker http://192.168.1.10:8080 \ --tracker http://192.168.1.10:8080 \
--port 8001 \ --port 8001 \
--host 0.0.0.0 \ --host 0.0.0.0 \
--advertise-host WINDOWS_LAN_IP --advertise-host WINDOWS_LAN_IP
``` ```
The `--advertise-host` flag tells the tracker what IP the Linux machine should use to reach this node. Use your Windows machine's LAN IP (e.g. `192.168.1.20`), **not** the WSL2 internal IP. The `--advertise-host` flag tells the tracker what IP the Linux machine should use to reach this node. Use your Windows machine's LAN IP (e.g. `192.168.1.20`), **not** the WSL2 internal IP.
Expected startup output: Expected startup output:
``` ```
Detecting hardware... Detecting hardware...
GPU: NVIDIA GeForce RTX 3080 (10240 MB VRAM) GPU: NVIDIA GeForce RTX 3080 (10240 MB VRAM)
Loading wallet... Loading wallet...
Wallet: 5K7r... Wallet: 5K7r...
Loading real PyTorch model shard... Loading real PyTorch model shard...
Auto-detected 40 layers → shard 2039 Auto-detected 40 layers → shard 2039
================================ ================================
meshnet-node ready meshnet-node ready
Model ID: microsoft/Phi-3-medium-128k-instruct Model ID: microsoft/Phi-3-medium-128k-instruct
Shard: layers 2039; 20 of 40 Shard: layers 2039; 20 of 40
Endpoint: http://192.168.1.20:8001 Endpoint: http://192.168.1.20:8001
Hardware: CUDA Hardware: CUDA
================================ ================================
``` ```
--- ---
## Known issues ## Known issues
- **WSL2 IP changes on restart.** Always re-run the `netsh` port-proxy command after restarting WSL2 or Windows. - **WSL2 IP changes on restart.** Always re-run the `netsh` port-proxy command after restarting WSL2 or Windows.
- **CUDA not visible in WSL2.** If `nvidia-smi` fails inside WSL2, update the Windows host GPU driver to ≥ 525.x. Installing CUDA inside WSL2 will not fix it. - **CUDA not visible in WSL2.** If `nvidia-smi` fails inside WSL2, update the Windows host GPU driver to ≥ 525.x. Installing CUDA inside WSL2 will not fix it.
- **Model download is slow.** HuggingFace downloads happen over HTTPS. Pre-fetch the model before a timed test (see Step 6). - **Model download is slow.** HuggingFace downloads happen over HTTPS. Pre-fetch the model before a timed test (see Step 6).
- **Port 8001 already in use.** Change `--port` to another value and update the firewall/portproxy rules accordingly. - **Port 8001 already in use.** Change `--port` to another value and update the firewall/portproxy rules accordingly.
- **`bf16` not supported on older GPUs.** Use `--quantization int8` on Turing (RTX 20xx) cards or earlier if bfloat16 ops fail. - **`bf16` not supported on older GPUs.** Use `--quantization int8` on Turing (RTX 20xx) cards or earlier if bfloat16 ops fail.

View File

@@ -1,48 +1,48 @@
# US-020 — Manual route selection + hop-penalty benchmarking # US-020 — Manual route selection + hop-penalty benchmarking
## Context ## Context
The tracker auto-selects inference routes based on synthetic benchmark scores. To measure The tracker auto-selects inference routes based on synthetic benchmark scores. To measure
the real cost of adding hops (latency per node boundary), we need: the real cost of adding hops (latency per node boundary), we need:
1. A way to pin a request to a specific route so we control the variable. 1. A way to pin a request to a specific route so we control the variable.
2. A benchmark endpoint that runs the same prompt through 1-node, 2-node, and 3-node 2. A benchmark endpoint that runs the same prompt through 1-node, 2-node, and 3-node
routes and records per-hop latency. routes and records per-hop latency.
Results are stored to disk. Routing algorithm is **not** changed in this story — this is Results are stored to disk. Routing algorithm is **not** changed in this story — this is
data collection only. The data will inform a future routing optimisation story. data collection only. The data will inform a future routing optimisation story.
## Design decisions (grilled 2026-07-01) ## Design decisions (grilled 2026-07-01)
| Decision | Choice | | Decision | Choice |
|---|---| |---|---|
| Route spec | Optional `route` field in JSON request body (list of node IDs) | | Route spec | Optional `route` field in JSON request body (list of node IDs) |
| Trigger | Explicit only — `POST /v1/benchmark/hop-penalty` endpoint | | Trigger | Explicit only — `POST /v1/benchmark/hop-penalty` endpoint |
| Auth | Header-presence stub (`Authorization` must be non-empty); real auth in future story | | Auth | Header-presence stub (`Authorization` must be non-empty); real auth in future story |
| Routing integration | Store data only; routing algorithm unchanged | | Routing integration | Store data only; routing algorithm unchanged |
| Persistence | Append to `benchmark_results.json` in tracker working dir; in-memory queryable | | Persistence | Append to `benchmark_results.json` in tracker working dir; in-memory queryable |
## Acceptance criteria ## Acceptance criteria
- `POST /v1/chat/completions` accepts optional `"route": ["<node_id>", ...]` in the - `POST /v1/chat/completions` accepts optional `"route": ["<node_id>", ...]` in the
request body. If present, the tracker uses those nodes in order instead of auto-selecting. request body. If present, the tracker uses those nodes in order instead of auto-selecting.
If absent, existing routing is unchanged (no breaking change for unaware clients). If absent, existing routing is unchanged (no breaking change for unaware clients).
- Missing or invalid node IDs in `route` return HTTP 400 with a descriptive error. - Missing or invalid node IDs in `route` return HTTP 400 with a descriptive error.
- `POST /v1/benchmark/hop-penalty` is auth-gated: requests without a non-empty - `POST /v1/benchmark/hop-penalty` is auth-gated: requests without a non-empty
`Authorization` header return HTTP 401. Body: `{"model": "...", "prompt": "...", `Authorization` header return HTTP 401. Body: `{"model": "...", "prompt": "...",
"max_new_tokens": 64}`. "max_new_tokens": 64}`.
- Benchmark fans out to up to three routes: 1-node (single node covering all layers), - Benchmark fans out to up to three routes: 1-node (single node covering all layers),
2-node (two consecutive shard nodes), 3-node (three nodes) — using whatever is 2-node (two consecutive shard nodes), 3-node (three nodes) — using whatever is
currently registered. Routes with insufficient coverage are skipped, not errored. currently registered. Routes with insufficient coverage are skipped, not errored.
- Response includes per-route breakdown: `total_ms`, `per_hop_ms: [...]`, - Response includes per-route breakdown: `total_ms`, `per_hop_ms: [...]`,
`tokens_generated`, `route: [node_id, ...]`. `tokens_generated`, `route: [node_id, ...]`.
- Results are appended to `<tracker_working_dir>/benchmark_results.json` (created if - Results are appended to `<tracker_working_dir>/benchmark_results.json` (created if
absent) as a JSON array. Each entry includes timestamp, model, prompt hash, and the absent) as a JSON array. Each entry includes timestamp, model, prompt hash, and the
per-route breakdown. per-route breakdown.
- `GET /v1/benchmark/results` returns the stored results array. Also auth-gated. - `GET /v1/benchmark/results` returns the stored results array. Also auth-gated.
- Clients that never send `route` or call `/v1/benchmark/*` are completely unaffected. - Clients that never send `route` or call `/v1/benchmark/*` are completely unaffected.
- Integration test: send the same prompt via a pinned 1-node route and a pinned 2-node - Integration test: send the same prompt via a pinned 1-node route and a pinned 2-node
route; assert 2-node result has 2 entries in `per_hop_ms`; assert both records appear route; assert 2-node result has 2 entries in `per_hop_ms`; assert both records appear
in `benchmark_results.json`. in `benchmark_results.json`.
- `python -m pytest` passes from repo root. - `python -m pytest` passes from repo root.
- Commit only this story's changes. - Commit only this story's changes.

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +1,34 @@
[build-system] [build-system]
requires = ["setuptools>=64"] requires = ["setuptools>=64"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "meshnet-node" name = "meshnet-node"
version = "0.1.0" version = "0.1.0"
description = "Distributed Inference Network node client" description = "Distributed Inference Network node client"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"cryptography>=41", "cryptography>=41",
"huggingface-hub>=0.20", "huggingface-hub>=0.20",
"accelerate>=0.28", "accelerate>=0.28",
"bitsandbytes>=0.43", "bitsandbytes>=0.43",
"rich>=13", "rich>=13",
"safetensors>=0.4", "safetensors>=0.4",
"torch>=2.1", "torch>=2.1",
"transformers>=5.12", "transformers>=5.12",
"triton-windows>=3.7; platform_system == 'Windows'", "triton-windows>=3.7; platform_system == 'Windows'",
"websockets>=13", "websockets>=13",
"zstandard>=0.22", "zstandard>=0.22",
"kernels>=0.11.1,<0.16", "kernels>=0.11.1,<0.16",
] ]
[project.scripts] [project.scripts]
meshnet-node = "meshnet_node.cli:main" meshnet-node = "meshnet_node.cli:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] where = ["."]
include = ["meshnet_node*"] include = ["meshnet_node*"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
meshnet_node = ["*.json"] meshnet_node = ["*.json"]

View File

@@ -1,419 +1,419 @@
"""meshnet-tracker CLI entry point.""" """meshnet-tracker CLI entry point."""
import argparse import argparse
import os import os
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
from .accounts import DEFAULT_ACCOUNTS_DB_PATH from .accounts import DEFAULT_ACCOUNTS_DB_PATH
from .billing import DEFAULT_BILLING_DB_PATH from .billing import DEFAULT_BILLING_DB_PATH
from .hf_pricing import DEFAULT_HF_PRICING_LOG_DB_PATH from .hf_pricing import DEFAULT_HF_PRICING_LOG_DB_PATH
from .logging_setup import ( from .logging_setup import (
DEFAULT_LOG_BACKUP_COUNT, DEFAULT_LOG_BACKUP_COUNT,
DEFAULT_LOG_DIR, DEFAULT_LOG_DIR,
DEFAULT_LOG_MAX_BYTES, DEFAULT_LOG_MAX_BYTES,
configure_tracker_file_logging, configure_tracker_file_logging,
) )
from .routing_stats import RoutingConfig from .routing_stats import RoutingConfig
from .server import ( from .server import (
DEFAULT_CALLER_CREDIT_USDT, DEFAULT_CALLER_CREDIT_USDT,
DEFAULT_DEVNET_TOPUP_USDT, DEFAULT_DEVNET_TOPUP_USDT,
TrackerServer, TrackerServer,
derive_relay_url_from_public_tracker_url, derive_relay_url_from_public_tracker_url,
) )
DEFAULT_REGISTRY_DB_PATH = "meshnet_registry.sqlite3" DEFAULT_REGISTRY_DB_PATH = "meshnet_registry.sqlite3"
def _load_env_file(path: Path) -> None: def _load_env_file(path: Path) -> None:
"""Load simple KEY=VALUE pairs from an env file without overriding env vars.""" """Load simple KEY=VALUE pairs from an env file without overriding env vars."""
if not path.exists(): if not path.exists():
return return
try: try:
lines = path.read_text().splitlines() lines = path.read_text().splitlines()
except OSError: except OSError:
return return
for line in lines: for line in lines:
text = line.strip() text = line.strip()
if not text or text.startswith("#"): if not text or text.startswith("#"):
continue continue
if text.startswith("export "): if text.startswith("export "):
text = text[len("export "):].strip() text = text[len("export "):].strip()
if "=" not in text: if "=" not in text:
continue continue
key, value = text.split("=", 1) key, value = text.split("=", 1)
key = key.strip() key = key.strip()
if not key or key in os.environ: if not key or key in os.environ:
continue continue
value = value.strip() value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
value = value[1:-1] value = value[1:-1]
os.environ[key] = value os.environ[key] = value
def _load_env_defaults() -> None: def _load_env_defaults() -> None:
"""Load local and user-level tracker env defaults before parsing arguments.""" """Load local and user-level tracker env defaults before parsing arguments."""
_load_env_file(Path.cwd() / ".env") _load_env_file(Path.cwd() / ".env")
_load_env_file(Path.home() / ".config" / "meshnet" / "secrets.env") _load_env_file(Path.home() / ".config" / "meshnet" / "secrets.env")
def _routing_config_from_args(args: argparse.Namespace) -> RoutingConfig | None: def _routing_config_from_args(args: argparse.Namespace) -> RoutingConfig | None:
"""Build a RoutingConfig from CLI flags; None keeps env-var/server defaults.""" """Build a RoutingConfig from CLI flags; None keeps env-var/server defaults."""
overrides = { overrides = {
"explore_share": args.route_explore_share, "explore_share": args.route_explore_share,
"weight_alpha": args.route_weight_alpha, "weight_alpha": args.route_weight_alpha,
"stats_half_life_seconds": args.route_stats_half_life, "stats_half_life_seconds": args.route_stats_half_life,
} }
set_values = {key: value for key, value in overrides.items() if value is not None} set_values = {key: value for key, value in overrides.items() if value is not None}
if not set_values: if not set_values:
return None return None
return RoutingConfig(**set_values) return RoutingConfig(**set_values)
def main() -> None: def main() -> None:
_load_env_defaults() _load_env_defaults()
common = argparse.ArgumentParser(add_help=False) common = argparse.ArgumentParser(add_help=False)
common.add_argument("--host", default="0.0.0.0", help="Host interface to listen on") common.add_argument("--host", default="0.0.0.0", help="Host interface to listen on")
common.add_argument("--port", type=int, default=8080, help="Port to listen on") common.add_argument("--port", type=int, default=8080, help="Port to listen on")
common.add_argument( common.add_argument(
"--heartbeat-timeout", "--heartbeat-timeout",
type=float, type=float,
default=30.0, default=30.0,
help="Seconds before a node is removed from the registry after missed heartbeat", help="Seconds before a node is removed from the registry after missed heartbeat",
) )
common.add_argument( common.add_argument(
"--cluster-peers", "--cluster-peers",
default="", default="",
help="Comma-separated URLs of peer tracker nodes (enables Raft cluster mode)", help="Comma-separated URLs of peer tracker nodes (enables Raft cluster mode)",
) )
common.add_argument( common.add_argument(
"--self-url", "--self-url",
default=None, default=None,
help="This tracker's own URL as seen by peers (auto-derived from --host/--port if omitted)", help="This tracker's own URL as seen by peers (auto-derived from --host/--port if omitted)",
) )
common.add_argument( common.add_argument(
"--stats-db", "--stats-db",
default=None, default=None,
metavar="PATH", metavar="PATH",
help="SQLite database path for persistent model usage statistics", help="SQLite database path for persistent model usage statistics",
) )
common.add_argument( common.add_argument(
"--relay-url", "--relay-url",
default=None, default=None,
help="Public ws(s):// relay URL advertised to nodes, for example wss://ai.neuron.d-popov.com/ws", help="Public ws(s):// relay URL advertised to nodes, for example wss://ai.neuron.d-popov.com/ws",
) )
common.add_argument( common.add_argument(
"--billing-db", "--billing-db",
default=DEFAULT_BILLING_DB_PATH, default=DEFAULT_BILLING_DB_PATH,
metavar="PATH", metavar="PATH",
help=( help=(
"SQLite database path for the USDT billing ledger " "SQLite database path for the USDT billing ledger "
f"(default: {DEFAULT_BILLING_DB_PATH}; ADR-0015)" f"(default: {DEFAULT_BILLING_DB_PATH}; ADR-0015)"
), ),
) )
common.add_argument( common.add_argument(
"--no-billing", "--no-billing",
action="store_true", action="store_true",
help="Disable the USDT billing ledger", help="Disable the USDT billing ledger",
) )
common.add_argument( common.add_argument(
"--max-charge-per-request", "--max-charge-per-request",
type=float, type=float,
default=None, default=None,
help=( help=(
"Reject chat completion requests whose prompt plus requested completion " "Reject chat completion requests whose prompt plus requested completion "
"token bound would cost more than this many USDT" "token bound would cost more than this many USDT"
), ),
) )
common.add_argument( common.add_argument(
"--starting-credit", "--starting-credit",
type=float, type=float,
default=DEFAULT_CALLER_CREDIT_USDT, default=DEFAULT_CALLER_CREDIT_USDT,
metavar="USDT", metavar="USDT",
help=( help=(
"One-time Caller Credit granted when an account creates its first " "One-time Caller Credit granted when an account creates its first "
f"API key (default: {DEFAULT_CALLER_CREDIT_USDT}; set 0 to require " f"API key (default: {DEFAULT_CALLER_CREDIT_USDT}; set 0 to require "
"deposits before inference)" "deposits before inference)"
), ),
) )
common.add_argument( common.add_argument(
"--devnet-topup", "--devnet-topup",
type=float, type=float,
default=DEFAULT_DEVNET_TOPUP_USDT, default=DEFAULT_DEVNET_TOPUP_USDT,
metavar="USDT", metavar="USDT",
help=( help=(
"Dashboard devnet top-up faucet: each click credits this many USDT " "Dashboard devnet top-up faucet: each click credits this many USDT "
f"to one of the account's keys (default: {DEFAULT_DEVNET_TOPUP_USDT}; " f"to one of the account's keys (default: {DEFAULT_DEVNET_TOPUP_USDT}; "
"MUST be 0 on mainnet deployments)" "MUST be 0 on mainnet deployments)"
), ),
) )
common.add_argument( common.add_argument(
"--registry-db", "--registry-db",
default=DEFAULT_REGISTRY_DB_PATH, default=DEFAULT_REGISTRY_DB_PATH,
metavar="PATH", metavar="PATH",
help=( help=(
"SQLite database path for persisted strike/ban/reputation registry " "SQLite database path for persisted strike/ban/reputation registry "
f"state (default: {DEFAULT_REGISTRY_DB_PATH})" f"state (default: {DEFAULT_REGISTRY_DB_PATH})"
), ),
) )
common.add_argument( common.add_argument(
"--no-registry-contracts", "--no-registry-contracts",
action="store_true", action="store_true",
help="Disable the local contract registry used for strike/ban/reputation enforcement", help="Disable the local contract registry used for strike/ban/reputation enforcement",
) )
common.add_argument( common.add_argument(
"--accounts-db", "--accounts-db",
default=DEFAULT_ACCOUNTS_DB_PATH, default=DEFAULT_ACCOUNTS_DB_PATH,
metavar="PATH", metavar="PATH",
help=( help=(
"SQLite database path for dashboard user accounts " "SQLite database path for dashboard user accounts "
f"(default: {DEFAULT_ACCOUNTS_DB_PATH})" f"(default: {DEFAULT_ACCOUNTS_DB_PATH})"
), ),
) )
common.add_argument( common.add_argument(
"--no-accounts", "--no-accounts",
action="store_true", action="store_true",
help="Disable dashboard user accounts (registration/login)", help="Disable dashboard user accounts (registration/login)",
) )
common.add_argument( common.add_argument(
"--solana-rpc-url", "--solana-rpc-url",
default=None, default=None,
help="Solana RPC URL (e.g. https://api.devnet.solana.com); enables the on-chain treasury", help="Solana RPC URL (e.g. https://api.devnet.solana.com); enables the on-chain treasury",
) )
common.add_argument( common.add_argument(
"--usdt-mint", "--usdt-mint",
default=None, default=None,
help="SPL mint address of (mock) USDT — see scripts/devnet_setup.py", help="SPL mint address of (mock) USDT — see scripts/devnet_setup.py",
) )
common.add_argument( common.add_argument(
"--treasury-keypair", "--treasury-keypair",
default=None, default=None,
metavar="PATH", metavar="PATH",
help="Treasury keypair JSON path (only on settlement-capable trackers)", help="Treasury keypair JSON path (only on settlement-capable trackers)",
) )
common.add_argument( common.add_argument(
"--settle-period", "--settle-period",
type=float, type=float,
default=86400.0, default=86400.0,
help="Max seconds between payouts to a node (dev: 60, prod: 86400)", help="Max seconds between payouts to a node (dev: 60, prod: 86400)",
) )
common.add_argument( common.add_argument(
"--payout-threshold", "--payout-threshold",
type=float, type=float,
default=5.0, default=5.0,
help="Pending USDT that triggers an immediate payout (dev: 0)", help="Pending USDT that triggers an immediate payout (dev: 0)",
) )
common.add_argument( common.add_argument(
"--payout-dust-floor", "--payout-dust-floor",
type=float, type=float,
default=0.01, default=0.01,
help="Never pay out less than this many USDT", help="Never pay out less than this many USDT",
) )
common.add_argument( common.add_argument(
"--validator-service-token", "--validator-service-token",
default=None, default=None,
help=( help=(
"Service token the validator uses on POST /v1/billing/forfeit " "Service token the validator uses on POST /v1/billing/forfeit "
"(default: MESHNET_VALIDATOR_SERVICE_TOKEN env; ADR-0017)" "(default: MESHNET_VALIDATOR_SERVICE_TOKEN env; ADR-0017)"
), ),
) )
common.add_argument( common.add_argument(
"--hive-secret", "--hive-secret",
default=None, default=None,
help=( help=(
"Shared secret authenticating gossip between tracker peers " "Shared secret authenticating gossip between tracker peers "
"(default: MESHNET_HIVE_SECRET env; required for multi-tracker replication)" "(default: MESHNET_HIVE_SECRET env; required for multi-tracker replication)"
), ),
) )
common.add_argument( common.add_argument(
"--toploc-calibration-db", "--toploc-calibration-db",
default=None, default=None,
metavar="PATH", metavar="PATH",
help=( help=(
"SQLite path for the AH-021 honest-noise TOPLOC calibration corpus " "SQLite path for the AH-021 honest-noise TOPLOC calibration corpus "
"(enables POST /v1/calibration/toploc/run + GET /v1/calibration/toploc/results)" "(enables POST /v1/calibration/toploc/run + GET /v1/calibration/toploc/results)"
), ),
) )
common.add_argument( common.add_argument(
"--toploc-reference-node-url", "--toploc-reference-node-url",
default=None, default=None,
help="Reference node the calibration job teacher-forces claimed tokens against (see validator README)", help="Reference node the calibration job teacher-forces claimed tokens against (see validator README)",
) )
common.add_argument( common.add_argument(
"--toploc-calibration-gate-min-hardware-profiles", "--toploc-calibration-gate-min-hardware-profiles",
type=int, type=int,
default=1, default=1,
help=( help=(
"Distinct (GPU model, dtype) profiles the corpus must cover before " "Distinct (GPU model, dtype) profiles the corpus must cover before "
"gate_status.ready is true (alpha exception: fleet size is acceptable)" "gate_status.ready is true (alpha exception: fleet size is acceptable)"
), ),
) )
common.add_argument( common.add_argument(
"--enable-hf-pricing", "--enable-hf-pricing",
action="store_true", action="store_true",
help=( help=(
"Enable the daily dynamic pricing refresh (issue 23): for presets with a " "Enable the daily dynamic pricing refresh (issue 23): for presets with a "
"curated hf_aliases list, sets the client price to 80%% of the cheapest " "curated hf_aliases list, sets the client price to 80%% of the cheapest "
"matching HuggingFace inference-marketplace rate. Presets without " "matching HuggingFace inference-marketplace rate. Presets without "
"hf_aliases are unaffected and keep their static price." "hf_aliases are unaffected and keep their static price."
), ),
) )
common.add_argument( common.add_argument(
"--hf-pricing-log-db", "--hf-pricing-log-db",
default=None, default=None,
metavar="PATH", metavar="PATH",
help=( help=(
"SQLite database path for the dynamic pricing change log " "SQLite database path for the dynamic pricing change log "
f"(default when --enable-hf-pricing is set: {DEFAULT_HF_PRICING_LOG_DB_PATH}; " f"(default when --enable-hf-pricing is set: {DEFAULT_HF_PRICING_LOG_DB_PATH}; "
"enables GET /v1/pricing/hf/history)" "enables GET /v1/pricing/hf/history)"
), ),
) )
common.add_argument( common.add_argument(
"--hf-pricing-refresh-interval", "--hf-pricing-refresh-interval",
type=float, type=float,
default=86400.0, default=86400.0,
help="Seconds between dynamic pricing refresh passes (default: daily)", help="Seconds between dynamic pricing refresh passes (default: daily)",
) )
common.add_argument( common.add_argument(
"--models-dir", "--models-dir",
default=None, default=None,
metavar="PATH", metavar="PATH",
help="Local HuggingFace snapshot root advertised as tracker model-file source (default: MESHNET_MODELS_DIR)", help="Local HuggingFace snapshot root advertised as tracker model-file source (default: MESHNET_MODELS_DIR)",
) )
common.add_argument( common.add_argument(
"--route-explore-share", "--route-explore-share",
type=float, type=float,
default=None, default=None,
metavar="FRACTION", metavar="FRACTION",
help=( help=(
"Fraction of requests routed down unproven/stale routes to gather " "Fraction of requests routed down unproven/stale routes to gather "
"throughput statistics (ADR-0021; default 0.3, lower once traffic grows)" "throughput statistics (ADR-0021; default 0.3, lower once traffic grows)"
), ),
) )
common.add_argument( common.add_argument(
"--route-weight-alpha", "--route-weight-alpha",
type=float, type=float,
default=None, default=None,
metavar="ALPHA", metavar="ALPHA",
help=( help=(
"Traffic weight exponent among proven routes: share ∝ tps^alpha " "Traffic weight exponent among proven routes: share ∝ tps^alpha "
"(default 1.0 — a 1.5x-faster route gets 1.5x the traffic)" "(default 1.0 — a 1.5x-faster route gets 1.5x the traffic)"
), ),
) )
common.add_argument( common.add_argument(
"--route-stats-half-life", "--route-stats-half-life",
type=float, type=float,
default=None, default=None,
metavar="SECONDS", metavar="SECONDS",
help="Half-life for decaying route throughput observations (default 600)", help="Half-life for decaying route throughput observations (default 600)",
) )
common.add_argument( common.add_argument(
"--log-dir", "--log-dir",
default=DEFAULT_LOG_DIR, default=DEFAULT_LOG_DIR,
metavar="PATH", metavar="PATH",
help=( help=(
"Directory for rotating tracker logs " "Directory for rotating tracker logs "
f"(default: {DEFAULT_LOG_DIR}; files: info.log, warning.log, error.log)" f"(default: {DEFAULT_LOG_DIR}; files: info.log, warning.log, error.log)"
), ),
) )
common.add_argument( common.add_argument(
"--log-max-bytes", "--log-max-bytes",
type=int, type=int,
default=DEFAULT_LOG_MAX_BYTES, default=DEFAULT_LOG_MAX_BYTES,
metavar="BYTES", metavar="BYTES",
help=f"Rotate each tracker log file after this many bytes (default: {DEFAULT_LOG_MAX_BYTES})", help=f"Rotate each tracker log file after this many bytes (default: {DEFAULT_LOG_MAX_BYTES})",
) )
common.add_argument( common.add_argument(
"--log-backup-count", "--log-backup-count",
type=int, type=int,
default=DEFAULT_LOG_BACKUP_COUNT, default=DEFAULT_LOG_BACKUP_COUNT,
metavar="N", metavar="N",
help=f"Number of rotated tracker log files to keep per level (default: {DEFAULT_LOG_BACKUP_COUNT})", help=f"Number of rotated tracker log files to keep per level (default: {DEFAULT_LOG_BACKUP_COUNT})",
) )
common.add_argument( common.add_argument(
"--no-file-logs", "--no-file-logs",
action="store_true", action="store_true",
help="Disable rotating tracker log files and only write to the terminal", help="Disable rotating tracker log files and only write to the terminal",
) )
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="meshnet-tracker", prog="meshnet-tracker",
description="Distributed Inference Network node registry and route selection", description="Distributed Inference Network node registry and route selection",
parents=[common], parents=[common],
) )
subparsers = parser.add_subparsers(dest="command") subparsers = parser.add_subparsers(dest="command")
subparsers.add_parser("start", help="Start the tracker server", parents=[common]) subparsers.add_parser("start", help="Start the tracker server", parents=[common])
args = parser.parse_args() args = parser.parse_args()
if args.command in {None, "start"}: if args.command in {None, "start"}:
if not args.no_file_logs: if not args.no_file_logs:
log_dir = configure_tracker_file_logging( log_dir = configure_tracker_file_logging(
args.log_dir, args.log_dir,
max_bytes=args.log_max_bytes, max_bytes=args.log_max_bytes,
backup_count=args.log_backup_count, backup_count=args.log_backup_count,
) )
print(f"meshnet-tracker logs: {log_dir}", flush=True) print(f"meshnet-tracker logs: {log_dir}", flush=True)
cluster_peers = [u.strip() for u in args.cluster_peers.split(",") if u.strip()] cluster_peers = [u.strip() for u in args.cluster_peers.split(",") if u.strip()]
relay_url = args.relay_url or derive_relay_url_from_public_tracker_url(args.self_url) relay_url = args.relay_url or derive_relay_url_from_public_tracker_url(args.self_url)
treasury = None treasury = None
if args.solana_rpc_url and args.usdt_mint and args.treasury_keypair: if args.solana_rpc_url and args.usdt_mint and args.treasury_keypair:
from meshnet_contracts.solana_adapter import SolanaCustodialTreasury from meshnet_contracts.solana_adapter import SolanaCustodialTreasury
treasury = SolanaCustodialTreasury( treasury = SolanaCustodialTreasury(
args.solana_rpc_url, args.usdt_mint, args.treasury_keypair, args.solana_rpc_url, args.usdt_mint, args.treasury_keypair,
) )
contracts = None contracts = None
if not args.no_registry_contracts: if not args.no_registry_contracts:
from meshnet_contracts import LocalSolanaContracts # type: ignore[import-not-found] from meshnet_contracts import LocalSolanaContracts # type: ignore[import-not-found]
contracts = LocalSolanaContracts(registry_db=args.registry_db) contracts = LocalSolanaContracts(registry_db=args.registry_db)
server = TrackerServer( server = TrackerServer(
host=args.host, host=args.host,
port=args.port, port=args.port,
heartbeat_timeout=args.heartbeat_timeout, heartbeat_timeout=args.heartbeat_timeout,
cluster_peers=cluster_peers or None, cluster_peers=cluster_peers or None,
cluster_self_url=args.self_url, cluster_self_url=args.self_url,
stats_db=getattr(args, "stats_db", None), stats_db=getattr(args, "stats_db", None),
relay_url=relay_url, relay_url=relay_url,
enable_billing=not args.no_billing, enable_billing=not args.no_billing,
billing_db=None if args.no_billing else args.billing_db, billing_db=None if args.no_billing else args.billing_db,
max_charge_per_request=args.max_charge_per_request, max_charge_per_request=args.max_charge_per_request,
starting_credit=args.starting_credit, starting_credit=args.starting_credit,
devnet_topup_amount=args.devnet_topup, devnet_topup_amount=args.devnet_topup,
contracts=contracts, contracts=contracts,
accounts_db=None if args.no_accounts else args.accounts_db, accounts_db=None if args.no_accounts else args.accounts_db,
treasury=treasury, treasury=treasury,
settle_period=args.settle_period, settle_period=args.settle_period,
payout_threshold=args.payout_threshold, payout_threshold=args.payout_threshold,
payout_dust_floor=args.payout_dust_floor, payout_dust_floor=args.payout_dust_floor,
validator_service_token=args.validator_service_token, validator_service_token=args.validator_service_token,
hive_secret=args.hive_secret, hive_secret=args.hive_secret,
toploc_calibration_db=args.toploc_calibration_db, toploc_calibration_db=args.toploc_calibration_db,
toploc_reference_node_url=args.toploc_reference_node_url, toploc_reference_node_url=args.toploc_reference_node_url,
toploc_calibration_gate_min_hardware_profiles=args.toploc_calibration_gate_min_hardware_profiles, toploc_calibration_gate_min_hardware_profiles=args.toploc_calibration_gate_min_hardware_profiles,
enable_hf_pricing=args.enable_hf_pricing, enable_hf_pricing=args.enable_hf_pricing,
hf_pricing_log_db=( hf_pricing_log_db=(
args.hf_pricing_log_db args.hf_pricing_log_db
or (DEFAULT_HF_PRICING_LOG_DB_PATH if args.enable_hf_pricing else None) 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, models_dir=args.models_dir,
routing_config=_routing_config_from_args(args), routing_config=_routing_config_from_args(args),
) )
port = server.start() port = server.start()
print(f"meshnet-tracker listening on http://{args.host}:{port}", flush=True) print(f"meshnet-tracker listening on http://{args.host}:{port}", flush=True)
try: try:
while True: while True:
time.sleep(1) time.sleep(1)
except KeyboardInterrupt: except KeyboardInterrupt:
server.stop() server.stop()
sys.exit(0) sys.exit(0)
else: else:
parser.print_help() parser.print_help()
if __name__ == "__main__": if __name__ == "__main__":
main() main()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,384 +1,384 @@
"""Dashboard user accounts: registration, login, roles, API keys, usage. """Dashboard user accounts: registration, login, roles, API keys, usage.
Unit tests for AccountStore plus HTTP integration on the tracker: Unit tests for AccountStore plus HTTP integration on the tracker:
register/login/logout, per-account balance and usage, API-key lifecycle register/login/logout, per-account balance and usage, API-key lifecycle
(revoked keys rejected by the OpenAI proxy), and the admin listing. (revoked keys rejected by the OpenAI proxy), and the admin listing.
""" """
import http.cookies import http.cookies
import json import json
import urllib.error import urllib.error
import urllib.request import urllib.request
import pytest import pytest
from meshnet_tracker.accounts import AccountStore from meshnet_tracker.accounts import AccountStore
from meshnet_tracker.auth import sign_hive_request from meshnet_tracker.auth import sign_hive_request
from meshnet_tracker.billing import BillingLedger from meshnet_tracker.billing import BillingLedger
from meshnet_tracker.server import TrackerServer from meshnet_tracker.server import TrackerServer
HIVE_SECRET = "test-hive-secret" HIVE_SECRET = "test-hive-secret"
# ---------------------------------------------------------------- unit tests # ---------------------------------------------------------------- unit tests
def test_first_account_is_admin_then_users(): def test_first_account_is_admin_then_users():
store = AccountStore() store = AccountStore()
first = store.register(email="admin@example.com", password="secret-123") first = store.register(email="admin@example.com", password="secret-123")
second = store.register(email="user@example.com", password="secret-123") second = store.register(email="user@example.com", password="secret-123")
assert first["role"] == "admin" assert first["role"] == "admin"
assert second["role"] == "user" assert second["role"] == "user"
def test_register_requires_email_or_wallet_and_password_length(): def test_register_requires_email_or_wallet_and_password_length():
store = AccountStore() store = AccountStore()
with pytest.raises(ValueError, match="email or a wallet"): with pytest.raises(ValueError, match="email or a wallet"):
store.register(password="secret-123") store.register(password="secret-123")
with pytest.raises(ValueError, match="invalid email"): with pytest.raises(ValueError, match="invalid email"):
store.register(email="not-an-email", password="secret-123") store.register(email="not-an-email", password="secret-123")
with pytest.raises(ValueError, match="at least 8"): with pytest.raises(ValueError, match="at least 8"):
store.register(email="a@b.co", password="short") store.register(email="a@b.co", password="short")
def test_register_rejects_duplicate_identifiers(): def test_register_rejects_duplicate_identifiers():
store = AccountStore() store = AccountStore()
store.register(email="dup@example.com", password="secret-123") store.register(email="dup@example.com", password="secret-123")
with pytest.raises(ValueError, match="already exists"): with pytest.raises(ValueError, match="already exists"):
store.register(email="DUP@example.com", password="other-secret") store.register(email="DUP@example.com", password="other-secret")
def test_login_by_email_or_wallet(): def test_login_by_email_or_wallet():
store = AccountStore() store = AccountStore()
account = store.register( account = store.register(
email="both@example.com", wallet="WalletXYZ", password="secret-123" email="both@example.com", wallet="WalletXYZ", password="secret-123"
) )
assert store.verify_login("both@example.com", "secret-123")["account_id"] == account["account_id"] assert store.verify_login("both@example.com", "secret-123")["account_id"] == account["account_id"]
assert store.verify_login("WalletXYZ", "secret-123")["account_id"] == account["account_id"] assert store.verify_login("WalletXYZ", "secret-123")["account_id"] == account["account_id"]
assert store.verify_login("both@example.com", "wrong-password") is None assert store.verify_login("both@example.com", "wrong-password") is None
assert store.verify_login("nobody@example.com", "secret-123") is None assert store.verify_login("nobody@example.com", "secret-123") is None
def test_sessions_resolve_and_destroy(): def test_sessions_resolve_and_destroy():
store = AccountStore() store = AccountStore()
account = store.register(email="s@example.com", password="secret-123") account = store.register(email="s@example.com", password="secret-123")
token = store.create_session(account["account_id"]) token = store.create_session(account["account_id"])
assert store.session_account(token)["account_id"] == account["account_id"] assert store.session_account(token)["account_id"] == account["account_id"]
store.destroy_session(token) store.destroy_session(token)
assert store.session_account(token) is None assert store.session_account(token) is None
assert store.session_account("bogus") is None assert store.session_account("bogus") is None
def test_sessions_persist_across_restart(tmp_path): def test_sessions_persist_across_restart(tmp_path):
db = str(tmp_path / "accounts.db") db = str(tmp_path / "accounts.db")
store = AccountStore(db_path=db) store = AccountStore(db_path=db)
account = store.register(email="cookie@example.com", password="secret-123") account = store.register(email="cookie@example.com", password="secret-123")
token = store.create_session(account["account_id"]) token = store.create_session(account["account_id"])
store.save_to_db() store.save_to_db()
reloaded = AccountStore(db_path=db) reloaded = AccountStore(db_path=db)
assert reloaded.session_account(token)["account_id"] == account["account_id"] assert reloaded.session_account(token)["account_id"] == account["account_id"]
def test_api_key_lifecycle(): def test_api_key_lifecycle():
store = AccountStore() store = AccountStore()
account = store.register(email="k@example.com", password="secret-123") account = store.register(email="k@example.com", password="secret-123")
other = store.register(email="other@example.com", password="secret-123") other = store.register(email="other@example.com", password="secret-123")
key = store.create_api_key(account["account_id"]) key = store.create_api_key(account["account_id"])
assert key.startswith("sk-mesh-") assert key.startswith("sk-mesh-")
assert store.keys_for(account["account_id"]) == [key] assert store.keys_for(account["account_id"]) == [key]
# someone else's account cannot revoke it # someone else's account cannot revoke it
assert store.revoke_api_key(other["account_id"], key) is False assert store.revoke_api_key(other["account_id"], key) is False
assert store.revoke_api_key(account["account_id"], key) is True assert store.revoke_api_key(account["account_id"], key) is True
assert store.keys_for(account["account_id"]) == [] assert store.keys_for(account["account_id"]) == []
assert store.is_key_revoked(key) assert store.is_key_revoked(key)
def test_accounts_persist_across_restart(tmp_path): def test_accounts_persist_across_restart(tmp_path):
db = str(tmp_path / "accounts.db") db = str(tmp_path / "accounts.db")
store = AccountStore(db_path=db) store = AccountStore(db_path=db)
account = store.register(email="p@example.com", password="secret-123") account = store.register(email="p@example.com", password="secret-123")
key = store.create_api_key(account["account_id"]) key = store.create_api_key(account["account_id"])
store.save_to_db() store.save_to_db()
reloaded = AccountStore(db_path=db) reloaded = AccountStore(db_path=db)
assert reloaded.verify_login("p@example.com", "secret-123") is not None assert reloaded.verify_login("p@example.com", "secret-123") is not None
assert reloaded.keys_for(account["account_id"]) == [key] assert reloaded.keys_for(account["account_id"]) == [key]
def test_account_events_replicate_and_dedupe(): def test_account_events_replicate_and_dedupe():
leader = AccountStore() leader = AccountStore()
follower = AccountStore() follower = AccountStore()
account = leader.register(email="r@example.com", password="secret-123") account = leader.register(email="r@example.com", password="secret-123")
key = leader.create_api_key(account["account_id"]) key = leader.create_api_key(account["account_id"])
leader.revoke_api_key(account["account_id"], key) leader.revoke_api_key(account["account_id"], key)
events, cursor = leader.events_since(0) events, cursor = leader.events_since(0)
assert follower.apply_events(events) == len(events) assert follower.apply_events(events) == len(events)
assert follower.apply_events(events) == 0 # replay is a no-op assert follower.apply_events(events) == 0 # replay is a no-op
assert follower.verify_login("r@example.com", "secret-123") is not None assert follower.verify_login("r@example.com", "secret-123") is not None
assert follower.is_key_revoked(key) assert follower.is_key_revoked(key)
more, _ = leader.events_since(cursor) more, _ = leader.events_since(cursor)
assert more == [] assert more == []
# ---------------------------------------------------------- HTTP integration # ---------------------------------------------------------- HTTP integration
def _call(url, method="GET", body=None, token=None): def _call(url, method="GET", body=None, token=None):
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
if token: if token:
headers["Authorization"] = f"Bearer {token}" headers["Authorization"] = f"Bearer {token}"
data = json.dumps(body).encode() if body is not None else None data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method) req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req) as r: with urllib.request.urlopen(req) as r:
return json.loads(r.read()) return json.loads(r.read())
@pytest.fixture @pytest.fixture
def account_tracker(): def account_tracker():
"""Tracker with credit features pinned OFF (defaults are devnet-friendly 1.0).""" """Tracker with credit features pinned OFF (defaults are devnet-friendly 1.0)."""
ledger = BillingLedger(starting_credit=0.0, default_price_per_1k=0.02) ledger = BillingLedger(starting_credit=0.0, default_price_per_1k=0.02)
tracker = TrackerServer( tracker = TrackerServer(
billing=ledger, billing=ledger,
accounts=AccountStore(), accounts=AccountStore(),
hive_secret=HIVE_SECRET, hive_secret=HIVE_SECRET,
starting_credit=0.0, starting_credit=0.0,
devnet_topup_amount=0.0, devnet_topup_amount=0.0,
) )
port = tracker.start() port = tracker.start()
yield f"http://127.0.0.1:{port}", ledger yield f"http://127.0.0.1:{port}", ledger
tracker.stop() tracker.stop()
def test_register_login_and_account_view(account_tracker): def test_register_login_and_account_view(account_tracker):
url, _ = account_tracker url, _ = account_tracker
reg = _call(f"{url}/v1/auth/register", "POST", reg = _call(f"{url}/v1/auth/register", "POST",
{"email": "admin@example.com", "password": "secret-123"}) {"email": "admin@example.com", "password": "secret-123"})
assert reg["account"]["role"] == "admin" assert reg["account"]["role"] == "admin"
assert reg["api_key"].startswith("sk-mesh-") assert reg["api_key"].startswith("sk-mesh-")
assert reg["session_token"] assert reg["session_token"]
login = _call(f"{url}/v1/auth/login", "POST", login = _call(f"{url}/v1/auth/login", "POST",
{"identifier": "admin@example.com", "password": "secret-123"}) {"identifier": "admin@example.com", "password": "secret-123"})
me = _call(f"{url}/v1/account", token=login["session_token"]) me = _call(f"{url}/v1/account", token=login["session_token"])
assert me["account"]["email"] == "admin@example.com" assert me["account"]["email"] == "admin@example.com"
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
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):
accounts_db = str(tmp_path / "accounts.db") accounts_db = str(tmp_path / "accounts.db")
tracker = TrackerServer( tracker = TrackerServer(
billing=BillingLedger(starting_credit=0.0, default_price_per_1k=0.02), billing=BillingLedger(starting_credit=0.0, default_price_per_1k=0.02),
accounts_db=accounts_db, accounts_db=accounts_db,
starting_credit=0.0, starting_credit=0.0,
devnet_topup_amount=0.0, devnet_topup_amount=0.0,
) )
port = tracker.start() port = tracker.start()
url = f"http://127.0.0.1:{port}" url = f"http://127.0.0.1:{port}"
try: try:
_call(f"{url}/v1/auth/register", "POST", _call(f"{url}/v1/auth/register", "POST",
{"email": "cookie-http@example.com", "password": "secret-123"}) {"email": "cookie-http@example.com", "password": "secret-123"})
req = urllib.request.Request( req = urllib.request.Request(
f"{url}/v1/auth/login", f"{url}/v1/auth/login",
data=json.dumps({ data=json.dumps({
"identifier": "cookie-http@example.com", "identifier": "cookie-http@example.com",
"password": "secret-123", "password": "secret-123",
}).encode(), }).encode(),
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
method="POST", method="POST",
) )
with urllib.request.urlopen(req) as r: with urllib.request.urlopen(req) as r:
assert json.loads(r.read())["session_token"] assert json.loads(r.read())["session_token"]
cookie_header = r.headers["Set-Cookie"] cookie_header = r.headers["Set-Cookie"]
finally: finally:
tracker.stop() tracker.stop()
cookie = http.cookies.SimpleCookie(cookie_header) cookie = http.cookies.SimpleCookie(cookie_header)
session_cookie = cookie["meshnet_session"].OutputString() session_cookie = cookie["meshnet_session"].OutputString()
restarted = TrackerServer( restarted = TrackerServer(
billing=BillingLedger(starting_credit=0.0, default_price_per_1k=0.02), billing=BillingLedger(starting_credit=0.0, default_price_per_1k=0.02),
accounts_db=accounts_db, accounts_db=accounts_db,
starting_credit=0.0, starting_credit=0.0,
devnet_topup_amount=0.0, devnet_topup_amount=0.0,
) )
restarted_port = restarted.start() restarted_port = restarted.start()
restarted_url = f"http://127.0.0.1:{restarted_port}" restarted_url = f"http://127.0.0.1:{restarted_port}"
try: try:
req = urllib.request.Request( req = urllib.request.Request(
f"{restarted_url}/v1/account", f"{restarted_url}/v1/account",
headers={"Cookie": session_cookie}, headers={"Cookie": session_cookie},
method="GET", method="GET",
) )
with urllib.request.urlopen(req) as r: with urllib.request.urlopen(req) as r:
me = json.loads(r.read()) me = json.loads(r.read())
finally: finally:
restarted.stop() restarted.stop()
assert me["account"]["email"] == "cookie-http@example.com" assert me["account"]["email"] == "cookie-http@example.com"
def test_bad_credentials_and_missing_session_are_401(account_tracker): def test_bad_credentials_and_missing_session_are_401(account_tracker):
url, _ = account_tracker url, _ = account_tracker
_call(f"{url}/v1/auth/register", "POST", _call(f"{url}/v1/auth/register", "POST",
{"email": "a@example.com", "password": "secret-123"}) {"email": "a@example.com", "password": "secret-123"})
with pytest.raises(urllib.error.HTTPError) as exc_info: with pytest.raises(urllib.error.HTTPError) as exc_info:
_call(f"{url}/v1/auth/login", "POST", _call(f"{url}/v1/auth/login", "POST",
{"identifier": "a@example.com", "password": "wrong-pass"}) {"identifier": "a@example.com", "password": "wrong-pass"})
assert exc_info.value.code == 401 assert exc_info.value.code == 401
with pytest.raises(urllib.error.HTTPError) as exc_info: with pytest.raises(urllib.error.HTTPError) as exc_info:
_call(f"{url}/v1/account") _call(f"{url}/v1/account")
assert exc_info.value.code == 401 assert exc_info.value.code == 401
def test_key_create_revoke_and_revoked_key_rejected_by_proxy(account_tracker): def test_key_create_revoke_and_revoked_key_rejected_by_proxy(account_tracker):
url, _ = account_tracker url, _ = account_tracker
reg = _call(f"{url}/v1/auth/register", "POST", reg = _call(f"{url}/v1/auth/register", "POST",
{"email": "k@example.com", "password": "secret-123"}) {"email": "k@example.com", "password": "secret-123"})
token = reg["session_token"] token = reg["session_token"]
new_key = _call(f"{url}/v1/account/keys", "POST", {}, token=token)["api_key"] new_key = _call(f"{url}/v1/account/keys", "POST", {}, token=token)["api_key"]
me = _call(f"{url}/v1/account", token=token) me = _call(f"{url}/v1/account", token=token)
assert sorted(me["api_keys"]) == sorted([reg["api_key"], new_key]) assert sorted(me["api_keys"]) == sorted([reg["api_key"], new_key])
_call(f"{url}/v1/account/keys/revoke", "POST", {"api_key": new_key}, token=token) _call(f"{url}/v1/account/keys/revoke", "POST", {"api_key": new_key}, token=token)
with pytest.raises(urllib.error.HTTPError) as exc_info: with pytest.raises(urllib.error.HTTPError) as exc_info:
_call(f"{url}/v1/chat/completions", "POST", _call(f"{url}/v1/chat/completions", "POST",
{"model": "any", "messages": []}, token=new_key) {"model": "any", "messages": []}, token=new_key)
assert exc_info.value.code == 401 assert exc_info.value.code == 401
assert "revoked" in exc_info.value.read().decode() assert "revoked" in exc_info.value.read().decode()
def test_admin_listing_requires_admin_role(account_tracker): def test_admin_listing_requires_admin_role(account_tracker):
url, _ = account_tracker url, _ = account_tracker
admin = _call(f"{url}/v1/auth/register", "POST", admin = _call(f"{url}/v1/auth/register", "POST",
{"email": "admin@example.com", "password": "secret-123"}) {"email": "admin@example.com", "password": "secret-123"})
user = _call(f"{url}/v1/auth/register", "POST", user = _call(f"{url}/v1/auth/register", "POST",
{"wallet": "WalletUser1", "password": "secret-123"}) {"wallet": "WalletUser1", "password": "secret-123"})
with pytest.raises(urllib.error.HTTPError) as exc_info: with pytest.raises(urllib.error.HTTPError) as exc_info:
_call(f"{url}/v1/admin/accounts", token=user["session_token"]) _call(f"{url}/v1/admin/accounts", token=user["session_token"])
assert exc_info.value.code == 403 assert exc_info.value.code == 403
listing = _call(f"{url}/v1/admin/accounts", token=admin["session_token"]) listing = _call(f"{url}/v1/admin/accounts", token=admin["session_token"])
accounts = listing["accounts"] accounts = listing["accounts"]
assert len(accounts) == 2 assert len(accounts) == 2
assert accounts[0]["role"] == "admin" assert accounts[0]["role"] == "admin"
assert accounts[1]["wallet"] == "WalletUser1" assert accounts[1]["wallet"] == "WalletUser1"
assert "balances" in accounts[0] assert "balances" in accounts[0]
def test_accounts_gossip_endpoint_applies_events(account_tracker): def test_accounts_gossip_endpoint_applies_events(account_tracker):
url, _ = account_tracker url, _ = account_tracker
peer = AccountStore() peer = AccountStore()
peer.register(email="remote@example.com", password="secret-123") peer.register(email="remote@example.com", password="secret-123")
events, _ = peer.events_since(0) events, _ = peer.events_since(0)
body = json.dumps({"events": events}).encode() body = json.dumps({"events": events}).encode()
req = urllib.request.Request( req = urllib.request.Request(
f"{url}/v1/accounts/gossip", data=body, f"{url}/v1/accounts/gossip", data=body,
headers={"Content-Type": "application/json", **sign_hive_request(HIVE_SECRET, body)}, headers={"Content-Type": "application/json", **sign_hive_request(HIVE_SECRET, body)},
method="POST", method="POST",
) )
with urllib.request.urlopen(req) as r: with urllib.request.urlopen(req) as r:
result = json.loads(r.read()) result = json.loads(r.read())
assert result["applied"] == len(events) assert result["applied"] == len(events)
login = _call(f"{url}/v1/auth/login", "POST", login = _call(f"{url}/v1/auth/login", "POST",
{"identifier": "remote@example.com", "password": "secret-123"}) {"identifier": "remote@example.com", "password": "secret-123"})
assert login["account"]["email"] == "remote@example.com" assert login["account"]["email"] == "remote@example.com"
def test_accounts_endpoints_404_when_disabled(): def test_accounts_endpoints_404_when_disabled():
tracker = TrackerServer() # no accounts, no billing tracker = TrackerServer() # no accounts, no billing
port = tracker.start() port = tracker.start()
try: try:
with pytest.raises(urllib.error.HTTPError) as exc_info: with pytest.raises(urllib.error.HTTPError) as exc_info:
_call(f"http://127.0.0.1:{port}/v1/auth/register", "POST", _call(f"http://127.0.0.1:{port}/v1/auth/register", "POST",
{"email": "x@example.com", "password": "secret-123"}) {"email": "x@example.com", "password": "secret-123"})
assert exc_info.value.code == 404 assert exc_info.value.code == 404
finally: finally:
tracker.stop() tracker.stop()
# ------------------------------------------- US-039/US-040: credit and top-up # ------------------------------------------- US-039/US-040: credit and top-up
@pytest.fixture @pytest.fixture
def funded_tracker(): def funded_tracker():
"""Tracker with Caller Credit and the devnet top-up faucet enabled.""" """Tracker with Caller Credit and the devnet top-up faucet enabled."""
ledger = BillingLedger(starting_credit=0.0, default_price_per_1k=0.02) ledger = BillingLedger(starting_credit=0.0, default_price_per_1k=0.02)
tracker = TrackerServer( tracker = TrackerServer(
billing=ledger, billing=ledger,
accounts=AccountStore(), accounts=AccountStore(),
hive_secret=HIVE_SECRET, hive_secret=HIVE_SECRET,
starting_credit=1.0, starting_credit=1.0,
devnet_topup_amount=10.0, devnet_topup_amount=10.0,
) )
port = tracker.start() port = tracker.start()
yield f"http://127.0.0.1:{port}", ledger yield f"http://127.0.0.1:{port}", ledger
tracker.stop() tracker.stop()
def test_caller_credit_granted_once_per_account(funded_tracker): def test_caller_credit_granted_once_per_account(funded_tracker):
url, ledger = funded_tracker url, ledger = funded_tracker
reg = _call(f"{url}/v1/auth/register", "POST", reg = _call(f"{url}/v1/auth/register", "POST",
{"email": "c@example.com", "password": "secret-123"}) {"email": "c@example.com", "password": "secret-123"})
token = reg["session_token"] token = reg["session_token"]
first_key = reg["api_key"] first_key = reg["api_key"]
assert ledger.get_client_balance(first_key) == pytest.approx(1.0) assert ledger.get_client_balance(first_key) == pytest.approx(1.0)
# A second key never re-grants — not even after revoking the first. # A second key never re-grants — not even after revoking the first.
second = _call(f"{url}/v1/account/keys", "POST", {}, token=token) second = _call(f"{url}/v1/account/keys", "POST", {}, token=token)
assert second["caller_credit_granted"] is False assert second["caller_credit_granted"] is False
assert ledger.get_client_balance(second["api_key"]) == pytest.approx(0.0) assert ledger.get_client_balance(second["api_key"]) == pytest.approx(0.0)
_call(f"{url}/v1/account/keys/revoke", "POST", {"api_key": first_key}, token=token) _call(f"{url}/v1/account/keys/revoke", "POST", {"api_key": first_key}, token=token)
third = _call(f"{url}/v1/account/keys", "POST", {}, token=token) third = _call(f"{url}/v1/account/keys", "POST", {}, token=token)
assert third["caller_credit_granted"] is False assert third["caller_credit_granted"] is False
assert ledger.get_client_balance(third["api_key"]) == pytest.approx(0.0) assert ledger.get_client_balance(third["api_key"]) == pytest.approx(0.0)
def test_unknown_bearer_key_rejected_by_proxy(funded_tracker): def test_unknown_bearer_key_rejected_by_proxy(funded_tracker):
url, ledger = funded_tracker url, ledger = funded_tracker
with pytest.raises(urllib.error.HTTPError) as exc_info: with pytest.raises(urllib.error.HTTPError) as exc_info:
_call(f"{url}/v1/chat/completions", "POST", _call(f"{url}/v1/chat/completions", "POST",
{"model": "any", "messages": []}, token="sk-mesh-made-up-key") {"model": "any", "messages": []}, token="sk-mesh-made-up-key")
assert exc_info.value.code == 401 assert exc_info.value.code == 401
assert "unknown API key" in exc_info.value.read().decode() assert "unknown API key" in exc_info.value.read().decode()
# The invented key must not have become a billable client. # The invented key must not have become a billable client.
assert ledger.get_client_balance("sk-mesh-made-up-key") == pytest.approx(0.0) assert ledger.get_client_balance("sk-mesh-made-up-key") == pytest.approx(0.0)
def test_devnet_topup_credits_own_key_only(funded_tracker): def test_devnet_topup_credits_own_key_only(funded_tracker):
url, ledger = funded_tracker url, ledger = funded_tracker
owner = _call(f"{url}/v1/auth/register", "POST", owner = _call(f"{url}/v1/auth/register", "POST",
{"email": "own@example.com", "password": "secret-123"}) {"email": "own@example.com", "password": "secret-123"})
other = _call(f"{url}/v1/auth/register", "POST", other = _call(f"{url}/v1/auth/register", "POST",
{"email": "oth@example.com", "password": "secret-123"}) {"email": "oth@example.com", "password": "secret-123"})
me = _call(f"{url}/v1/account", token=owner["session_token"]) me = _call(f"{url}/v1/account", token=owner["session_token"])
assert me["topup_amount"] == pytest.approx(10.0) assert me["topup_amount"] == pytest.approx(10.0)
result = _call(f"{url}/v1/account/topup", "POST", result = _call(f"{url}/v1/account/topup", "POST",
{"api_key": owner["api_key"]}, token=owner["session_token"]) {"api_key": owner["api_key"]}, token=owner["session_token"])
assert result["credited"] == pytest.approx(10.0) assert result["credited"] == pytest.approx(10.0)
assert result["balance"] == pytest.approx(11.0) # 1.0 caller credit + 10.0 assert result["balance"] == pytest.approx(11.0) # 1.0 caller credit + 10.0
with pytest.raises(urllib.error.HTTPError) as exc_info: with pytest.raises(urllib.error.HTTPError) as exc_info:
_call(f"{url}/v1/account/topup", "POST", _call(f"{url}/v1/account/topup", "POST",
{"api_key": owner["api_key"]}, token=other["session_token"]) {"api_key": owner["api_key"]}, token=other["session_token"])
assert exc_info.value.code == 403 assert exc_info.value.code == 403
assert ledger.get_client_balance(owner["api_key"]) == pytest.approx(11.0) assert ledger.get_client_balance(owner["api_key"]) == pytest.approx(11.0)
def test_topup_404_when_disabled(account_tracker): def test_topup_404_when_disabled(account_tracker):
url, _ = account_tracker url, _ = account_tracker
reg = _call(f"{url}/v1/auth/register", "POST", reg = _call(f"{url}/v1/auth/register", "POST",
{"email": "t@example.com", "password": "secret-123"}) {"email": "t@example.com", "password": "secret-123"})
me = _call(f"{url}/v1/account", token=reg["session_token"]) me = _call(f"{url}/v1/account", token=reg["session_token"])
assert me["topup_amount"] == pytest.approx(0.0) assert me["topup_amount"] == pytest.approx(0.0)
with pytest.raises(urllib.error.HTTPError) as exc_info: with pytest.raises(urllib.error.HTTPError) as exc_info:
_call(f"{url}/v1/account/topup", "POST", _call(f"{url}/v1/account/topup", "POST",
{"api_key": reg["api_key"]}, token=reg["session_token"]) {"api_key": reg["api_key"]}, token=reg["session_token"])
assert exc_info.value.code == 404 assert exc_info.value.code == 404

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff