frutebox-node 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- frutebox/adminheader.py +59 -0
- frutebox/attest.py +43 -0
- frutebox/audiogen.py +364 -0
- frutebox/config.py +367 -0
- frutebox/deltasync.py +211 -0
- frutebox/economics.py +270 -0
- frutebox/engine_guard.py +718 -0
- frutebox/envelope.py +77 -0
- frutebox/envvars.py +167 -0
- frutebox/gpuprices.py +641 -0
- frutebox/gpuproviders.py +4489 -0
- frutebox/gui.py +270 -0
- frutebox/imagegen.py +152 -0
- frutebox/inference.py +624 -0
- frutebox/manifest.py +218 -0
- frutebox/mediaserving.py +603 -0
- frutebox/packaging.py +542 -0
- frutebox/pqctransport.py +660 -0
- frutebox/provider.py +1867 -0
- frutebox/providers.py +942 -0
- frutebox/registry/__init__.py +189 -0
- frutebox/registry/generate.py +311 -0
- frutebox/registry/generate_providers.py +120 -0
- frutebox/registry/models.yaml +1759 -0
- frutebox/registry/providers.yaml +518 -0
- frutebox/registry/registry.gen.json +2043 -0
- frutebox/registry_gen.py +3 -0
- frutebox/runpod_templates.py +143 -0
- frutebox/serving.py +691 -0
- frutebox/signedconfig.py +259 -0
- frutebox/signing.py +208 -0
- frutebox/thinkbudget.py +155 -0
- frutebox/videogen.py +118 -0
- frutebox_node-0.1.0.dist-info/METADATA +100 -0
- frutebox_node-0.1.0.dist-info/RECORD +36 -0
- frutebox_node-0.1.0.dist-info/WHEEL +4 -0
frutebox/provider.py
ADDED
|
@@ -0,0 +1,1867 @@
|
|
|
1
|
+
"""frutebox provider node.
|
|
2
|
+
|
|
3
|
+
This is the piece an organisation or individual installs on their own server /
|
|
4
|
+
GPU box (the "downloadable software"). It makes a single *outbound* WebSocket
|
|
5
|
+
connection to a network **gateway** and receives inference jobs over it. Because
|
|
6
|
+
the connection is outbound, the node needs no public IP, no inbound firewall
|
|
7
|
+
rules and no port forwarding — it works from inside a corporate network or
|
|
8
|
+
behind home NAT. Prompts arrive from the gateway (the network), never from the
|
|
9
|
+
Google-Cloud directory.
|
|
10
|
+
|
|
11
|
+
Run:
|
|
12
|
+
python -m frutebox.provider --name berlin-gpu-1 --region eu-central \\
|
|
13
|
+
--gateway-url ws://127.0.0.1:8090/provider/ws
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import subprocess
|
|
22
|
+
import time
|
|
23
|
+
import uuid
|
|
24
|
+
from collections import deque
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Deque
|
|
27
|
+
|
|
28
|
+
import websockets
|
|
29
|
+
|
|
30
|
+
from . import attest, engine_guard, inference, packaging
|
|
31
|
+
from .config import CONFIG
|
|
32
|
+
from .envvars import env as envvar
|
|
33
|
+
|
|
34
|
+
#: Reply fields that carry CONTENT and must be sealed back to the gateway (file 22-A1). An
|
|
35
|
+
#: embedding is a lossy but real function of the user's text — the owner's ruling treats it as
|
|
36
|
+
#: user data — so it may no more travel home in the clear than the document travelled out in it.
|
|
37
|
+
#: `type`, `id` and `model` stay readable: the gateway matches and meters the reply on those.
|
|
38
|
+
#:
|
|
39
|
+
#: PHASE TWO of the `result` cutover adds the answer itself. A completion is the user's data
|
|
40
|
+
#: exactly as much as their prompt is, and it used to come home in the clear on every chat turn:
|
|
41
|
+
#: `content` and `reasoning` are what the model wrote, `tool_calls` are the arguments it chose
|
|
42
|
+
#: (a tool call carries the user's own words as often as not), `text` is one streamed delta of
|
|
43
|
+
#: the same answer, and `artifact` is a whole generated image/audio/video, base64 on the frame.
|
|
44
|
+
#:
|
|
45
|
+
#: WHAT DELIBERATELY STAYS READABLE: `tokens`, `prompt_tokens`, `backend` and `compute_ms`. The
|
|
46
|
+
#: gateway meters and reputation-scores on those, they are counts and names rather than content,
|
|
47
|
+
#: and they are the same class of routing metadata that `_seal_job` leaves in the clear on the
|
|
48
|
+
#: way out. `channel` stays readable for the same reason — it says which stream a delta belongs
|
|
49
|
+
#: on, not what is in it.
|
|
50
|
+
_SEALED_REPLY_FIELDS = ("embeddings", "content", "reasoning", "tool_calls", "text", "artifact")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _provider_ws_url(public_url: str) -> str:
|
|
54
|
+
"""The gateway's provider socket, derived from the https url it heartbeats to the directory.
|
|
55
|
+
|
|
56
|
+
The directory stores what a gateway calls itself (`STEVAI_GATEWAY_URL`), which is an http(s)
|
|
57
|
+
origin. Nodes need the WebSocket endpoint on that same origin, so the scheme is upgraded and
|
|
58
|
+
the well-known path appended — rather than asking every gateway to publish a second url that
|
|
59
|
+
could drift out of step with the first.
|
|
60
|
+
"""
|
|
61
|
+
u = (public_url or "").strip().rstrip("/")
|
|
62
|
+
if not u:
|
|
63
|
+
return ""
|
|
64
|
+
if u.startswith("https://"):
|
|
65
|
+
u = "wss://" + u[len("https://"):]
|
|
66
|
+
elif u.startswith("http://"):
|
|
67
|
+
u = "ws://" + u[len("http://"):]
|
|
68
|
+
elif not u.startswith(("ws://", "wss://")):
|
|
69
|
+
return ""
|
|
70
|
+
return u if u.endswith("/provider/ws") else u + "/provider/ws"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _detect_gpu_vram_gb() -> float:
|
|
74
|
+
"""Best-effort total GPU VRAM in GB via nvidia-smi; 0.0 if unavailable.
|
|
75
|
+
|
|
76
|
+
Lets a real GPU (e.g. an A40's ~48GB) declare its true capacity so the
|
|
77
|
+
directory tiers it correctly instead of defaulting to the CPU/small tier.
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
out = subprocess.run(
|
|
81
|
+
["nvidia-smi", "--query-gpu=memory.total", "--format=csv,noheader,nounits"],
|
|
82
|
+
capture_output=True, text=True, timeout=5,
|
|
83
|
+
)
|
|
84
|
+
if out.returncode == 0:
|
|
85
|
+
mibs = [int(x) for x in out.stdout.split() if x.strip().isdigit()]
|
|
86
|
+
if mibs:
|
|
87
|
+
return round(max(mibs) / 1024.0, 1)
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
return 0.0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _detect_system_memory_gb() -> float:
|
|
94
|
+
"""Best-effort total system RAM in GB (stdlib only); 0.0 if undetermined.
|
|
95
|
+
|
|
96
|
+
Lets an ordinary laptop/desktop declare its REAL RAM to the directory instead
|
|
97
|
+
of a hardcoded guess, so CPU-RAM pooling budgets are honest. Linux: parse
|
|
98
|
+
/proc/meminfo (MemTotal, kB). macOS/BSD: `sysctl -n hw.memsize` (bytes). Last
|
|
99
|
+
resort: POSIX sysconf. No new pip deps.
|
|
100
|
+
"""
|
|
101
|
+
# Linux (and any OS exposing /proc/meminfo).
|
|
102
|
+
try:
|
|
103
|
+
with open("/proc/meminfo", "r") as f:
|
|
104
|
+
for line in f:
|
|
105
|
+
if line.startswith("MemTotal:"):
|
|
106
|
+
return round(int(line.split()[1]) / (1024.0 * 1024.0), 1)
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
# macOS / BSD: hw.memsize is total physical RAM in bytes.
|
|
110
|
+
try:
|
|
111
|
+
out = subprocess.run(["sysctl", "-n", "hw.memsize"],
|
|
112
|
+
capture_output=True, text=True, timeout=5)
|
|
113
|
+
if out.returncode == 0 and out.stdout.strip().isdigit():
|
|
114
|
+
return round(int(out.stdout.strip()) / (1024.0 ** 3), 1)
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
# POSIX fallback (many Unixes expose these sysconf names).
|
|
118
|
+
try:
|
|
119
|
+
pages = os.sysconf("SC_PHYS_PAGES")
|
|
120
|
+
page_size = os.sysconf("SC_PAGE_SIZE")
|
|
121
|
+
if pages > 0 and page_size > 0:
|
|
122
|
+
return round(pages * page_size / (1024.0 ** 3), 1)
|
|
123
|
+
except (ValueError, OSError, AttributeError):
|
|
124
|
+
pass
|
|
125
|
+
return 0.0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _detect_cpu_cores() -> int:
|
|
129
|
+
"""Logical CPU count via the stdlib; 1 if the OS won't say."""
|
|
130
|
+
return os.cpu_count() or 1
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# Gateway close codes with a meaning for the client (mirrors frutebox/gateway.py).
|
|
134
|
+
# Blind-retrying either of these is what turned a lapsed grant into a permanent
|
|
135
|
+
# eviction, so they are handled explicitly in run().
|
|
136
|
+
_CLOSE_REJECTED = 4003 # credential refused (or attestation failed)
|
|
137
|
+
_CLOSE_DUPLICATE = 4004 # this node id already has a live socket on that gateway
|
|
138
|
+
_CLOSE_CRED_EXPIRED = 4401 # credential merely lapsed — fetch a new one
|
|
139
|
+
_CLOSE_ROTATED = 4005 # our OWN replacement socket took over (F-20.3)
|
|
140
|
+
# How long to wait before dialling again once the gateway told us the credential is
|
|
141
|
+
# no good. Far longer than the normal backoff: nothing changes by trying sooner, and
|
|
142
|
+
# a tight loop is exactly the pattern that looked like an attack.
|
|
143
|
+
_CRED_RETRY_S = 60.0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
#: Where the node publishes whether it is ACTUALLY joined to a gateway. The RunPod status sidecar
|
|
147
|
+
#: serves this file, and it exists because the boot-state file could not answer the only question
|
|
148
|
+
#: that matters. `ready` there means "engine up, models pulled, agent about to launch" — the
|
|
149
|
+
#: entrypoint says so in full — and a pod on 2026-09-28 sat at `ready`, both models loaded, with
|
|
150
|
+
#: the gateway's provider count at 0 for its whole life. Another did the same for ~18 hours on
|
|
151
|
+
#: 2026-09-29, billing the entire time, and nothing on the pod could be asked "are you serving?".
|
|
152
|
+
#: The node is the only party that knows, so the node writes it down.
|
|
153
|
+
NODE_STATE_FILE = envvar("NODE_STATE_FILE", "/var/run/frutebox_node_state")
|
|
154
|
+
|
|
155
|
+
#: Rotate a socket once it reaches this age (F-20.3, make-before-break). It must sit comfortably
|
|
156
|
+
#: BELOW the gateway's request timeout — Cloud Run caps a WebSocket at `timeoutSeconds`, currently
|
|
157
|
+
#: 3600 — because the point is to replace the socket on OUR schedule, while it is still healthy,
|
|
158
|
+
#: rather than to discover it gone on the platform's.
|
|
159
|
+
SOCKET_MAX_AGE_S = float(envvar("SOCKET_MAX_AGE_S", "3000"))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _publish_link_state(state: str) -> None:
|
|
163
|
+
"""Record the gateway link's state. Best effort, and NEVER fatal.
|
|
164
|
+
|
|
165
|
+
A laptop node has no business failing because `/var/run` is not writable, so every error is
|
|
166
|
+
swallowed — an unobservable node is a smaller problem than a node that will not run.
|
|
167
|
+
"""
|
|
168
|
+
try:
|
|
169
|
+
with open(NODE_STATE_FILE, "w") as fh:
|
|
170
|
+
fh.write(f"{state} {time.time():.0f}\n")
|
|
171
|
+
except Exception: # noqa: BLE001
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _close_code(exc: BaseException) -> int:
|
|
176
|
+
"""Best-effort WebSocket close code from a websockets exception, across versions
|
|
177
|
+
(`.code` on older releases, `.rcvd.code` on newer ones). 0 = unknown."""
|
|
178
|
+
code = getattr(exc, "code", None)
|
|
179
|
+
if isinstance(code, int):
|
|
180
|
+
return code
|
|
181
|
+
rcvd = getattr(exc, "rcvd", None)
|
|
182
|
+
code = getattr(rcvd, "code", None)
|
|
183
|
+
return code if isinstance(code, int) else 0
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _frame_id(msg: dict):
|
|
187
|
+
"""A HASHABLE job id for a frame off the wire.
|
|
188
|
+
|
|
189
|
+
The gateway sends a string, but the frame is attacker-influenced data and a list or
|
|
190
|
+
dict there would raise ``TypeError: unhashable`` from ``set.add``/``set.discard`` —
|
|
191
|
+
including from the ``finally`` that releases the concurrency slot, which turns a
|
|
192
|
+
typo into a leaked slot.
|
|
193
|
+
"""
|
|
194
|
+
jid = msg.get("id")
|
|
195
|
+
if isinstance(jid, (str, int, float, bool)) or jid is None:
|
|
196
|
+
return jid
|
|
197
|
+
return repr(jid)[:256]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _load_credential(args) -> str:
|
|
201
|
+
"""Resolve this node's signed admission credential: --credential, else the file
|
|
202
|
+
named by --credential-file, else $FRUTEBOX_NODE_CREDENTIAL (or the legacy
|
|
203
|
+
$STEVAI_NODE_CREDENTIAL), else "" (no credential).
|
|
204
|
+
|
|
205
|
+
A credential is ~4.5 KB (hybrid Ed25519 + ML-DSA-65 signatures), so the FILE form
|
|
206
|
+
is the practical one for a service unit or a shell — and it never lands in the
|
|
207
|
+
process list the way a flag does. It is a bearer grant for this node id: keep it
|
|
208
|
+
out of logs and never put it in a URL.
|
|
209
|
+
"""
|
|
210
|
+
direct = (getattr(args, "credential", "") or "").strip()
|
|
211
|
+
if direct:
|
|
212
|
+
return direct
|
|
213
|
+
path = (getattr(args, "credential_file", "") or "").strip()
|
|
214
|
+
if path:
|
|
215
|
+
try:
|
|
216
|
+
return Path(path).read_text(encoding="utf-8").strip()
|
|
217
|
+
except OSError as e:
|
|
218
|
+
print(f"[node] WARNING: could not read --credential-file {path}: "
|
|
219
|
+
f"{type(e).__name__} — connecting without a credential")
|
|
220
|
+
return envvar("NODE_CREDENTIAL", "").strip()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class ProviderNode:
|
|
224
|
+
def __init__(self, args):
|
|
225
|
+
self.id = args.id or f"{args.region}-{uuid.uuid4().hex[:6]}"
|
|
226
|
+
self.name = args.name or self.id
|
|
227
|
+
self.region = args.region
|
|
228
|
+
# Hardware class. An explicit --compute-type wins; otherwise AUTO-DETECT:
|
|
229
|
+
# a real GPU (nvidia-smi reports VRAM) → "gpu", else fall back to "cpu" so
|
|
230
|
+
# a plain laptop/desktop contributes as a CPU node instead of falsely
|
|
231
|
+
# declaring a GPU it doesn't have.
|
|
232
|
+
vram_gb = _detect_gpu_vram_gb()
|
|
233
|
+
self.compute_type = getattr(args, "compute_type", None) or (
|
|
234
|
+
"gpu" if vram_gb > 0 else "cpu")
|
|
235
|
+
# Memory this node contributes (VRAM for a GPU, system RAM for a CPU).
|
|
236
|
+
# This is what lets many small CPUs POOL their RAM to hold a big model.
|
|
237
|
+
self.memory_gb = float(getattr(args, "memory_gb", 0.0) or 0.0)
|
|
238
|
+
if self.memory_gb <= 0: # not declared → measure the real hardware, don't guess
|
|
239
|
+
if self.compute_type == "gpu":
|
|
240
|
+
self.memory_gb = vram_gb or 24.0
|
|
241
|
+
else:
|
|
242
|
+
self.memory_gb = _detect_system_memory_gb() or 32.0
|
|
243
|
+
# CPU cores: explicit flag wins, else probe the machine (stdlib os.cpu_count()).
|
|
244
|
+
self.cpu_cores = int(getattr(args, "cpu_cores", 0) or 0) or _detect_cpu_cores()
|
|
245
|
+
self.models = [m.strip() for m in args.models.split(",") if m.strip()]
|
|
246
|
+
if not self.models: # advertise the concrete model this node actually runs,
|
|
247
|
+
# A COMMA-SEPARATED LIST, same as `--models`. One card can hold several models
|
|
248
|
+
# (a 48 GB A40 fits qwen2.5:32b + gemma-27b), and treating the env value as a
|
|
249
|
+
# single name would advertise a model called "qwen2.5:32b,gemma-27b" — which
|
|
250
|
+
# matches nothing in routing, so the node registers and is never routed to.
|
|
251
|
+
raw = envvar("OLLAMA_MODEL", "") # not "serves anything"
|
|
252
|
+
self.models = [m.strip() for m in raw.split(",") if m.strip()]
|
|
253
|
+
self.cost_per_1k = args.cost_per_1k
|
|
254
|
+
# Simultaneous jobs this node will actually run. Advertised to the directory AND
|
|
255
|
+
# enforced below — before, every inbound job frame became a task regardless, so
|
|
256
|
+
# one user could start unlimited generations on somebody's laptop. An explicit
|
|
257
|
+
# --concurrency wins; otherwise the node-governance default applies.
|
|
258
|
+
self.max_concurrency = max(1, int(getattr(args, "concurrency", 0)
|
|
259
|
+
or CONFIG.node_max_concurrent_jobs))
|
|
260
|
+
# Hard wall-clock ceiling per job (cancel included).
|
|
261
|
+
self.job_timeout_s = float(getattr(args, "job_timeout_s", 0.0)
|
|
262
|
+
or CONFIG.node_job_timeout_s)
|
|
263
|
+
self.gateway_url = args.gateway_url
|
|
264
|
+
# Signed admission credential (minted by the directory for THIS node id and
|
|
265
|
+
# handed to the machine's owner). Presented on every (re)connect; the gateway
|
|
266
|
+
# verifies the operator's signature and admits us without waiting for a manual
|
|
267
|
+
# approval that would be lost on the next control-plane restart. Optional —
|
|
268
|
+
# without one we fall back to the digest + approval-list path.
|
|
269
|
+
# `_cred_args` is kept so the credential can be RE-READ before each connect:
|
|
270
|
+
# dropping a fresh file at --credential-file is then enough to recover from an
|
|
271
|
+
# expiry, with no restart and no reconnect loop hammering the stale one.
|
|
272
|
+
self._cred_args = args
|
|
273
|
+
self.credential = _load_credential(args)
|
|
274
|
+
# What this node can GENERATE. Ollama chat models are text-only; a capable
|
|
275
|
+
# node (image/audio/video) advertises more via STEVAI_GEN_MODALITIES.
|
|
276
|
+
self.output_modalities = [
|
|
277
|
+
m.strip() for m in envvar("GEN_MODALITIES", "text").split(",")
|
|
278
|
+
if m.strip()] or ["text"]
|
|
279
|
+
# PLAN 23 §B2 — "The router never offers a video/music job to a Tier-C/edge node —
|
|
280
|
+
# Tier B/A GPUs only (8-16 GB+)." The refusal belongs HERE, at the advertisement,
|
|
281
|
+
# not only at the router: a node that advertises a modality it must then refuse
|
|
282
|
+
# wastes a dispatch and looks like capacity that does not exist. A machine below the
|
|
283
|
+
# 8 GB floor stays text-only no matter which model libraries happen to be installed
|
|
284
|
+
# on it, and `GEN_MODALITIES` cannot be used to opt an edge node back in.
|
|
285
|
+
# Guarded like every other media import in this block: an import failure here must
|
|
286
|
+
# degrade the node to text-only, not stop it starting. A node that serves text is
|
|
287
|
+
# useful; a node that refuses to boot is not.
|
|
288
|
+
try:
|
|
289
|
+
from . import mediaserving
|
|
290
|
+
from .imagegen import device_vram_gb
|
|
291
|
+
_vram = device_vram_gb()
|
|
292
|
+
self.media_tier = mediaserving.node_tier_for_vram(_vram)
|
|
293
|
+
self.output_modalities = mediaserving.media_modalities_for_node(
|
|
294
|
+
self.output_modalities, _vram)
|
|
295
|
+
_media_ok = mediaserving.tier_accepts(self.media_tier)
|
|
296
|
+
except Exception:
|
|
297
|
+
self.media_tier = "C"
|
|
298
|
+
self.output_modalities = [m for m in self.output_modalities
|
|
299
|
+
if m not in ("image", "audio", "video")] or ["text"]
|
|
300
|
+
_media_ok = False
|
|
301
|
+
if _media_ok:
|
|
302
|
+
# Auto-advertise generation modalities this node can actually serve (the model libs
|
|
303
|
+
# are only present on a capable GPU node).
|
|
304
|
+
try:
|
|
305
|
+
from . import imagegen
|
|
306
|
+
if imagegen.available() and "image" not in self.output_modalities:
|
|
307
|
+
self.output_modalities.append("image")
|
|
308
|
+
except Exception:
|
|
309
|
+
pass
|
|
310
|
+
try:
|
|
311
|
+
from . import audiogen
|
|
312
|
+
if audiogen.available() and "audio" not in self.output_modalities:
|
|
313
|
+
self.output_modalities.append("audio")
|
|
314
|
+
except Exception:
|
|
315
|
+
pass
|
|
316
|
+
try:
|
|
317
|
+
from . import videogen
|
|
318
|
+
if videogen.available() and "video" not in self.output_modalities:
|
|
319
|
+
self.output_modalities.append("video")
|
|
320
|
+
except Exception:
|
|
321
|
+
pass
|
|
322
|
+
|
|
323
|
+
# AND DROP WHAT THIS NODE CANNOT ACTUALLY DO, however it came to be listed.
|
|
324
|
+
#
|
|
325
|
+
# The three blocks above only ever ADD. A modality named in `GEN_MODALITIES` was therefore
|
|
326
|
+
# advertised whether or not its generator could run, and `mediaserving` says plainly why
|
|
327
|
+
# that is wrong: "A node that advertises a modality it must then refuse is capacity that
|
|
328
|
+
# does not exist: the dispatch is wasted and the router has no way to know."
|
|
329
|
+
#
|
|
330
|
+
# MEASURED 2026-10-14. The media pod's spec sets GEN_MODALITIES=text,image,audio,video, and
|
|
331
|
+
# its boot had just deleted the venv holding torch. So it advertised `image`, cleared the
|
|
332
|
+
# gateway's modality gate, took the job, and answered "No module named 'torch'" — 38 times.
|
|
333
|
+
# The gateway reported "no node is serving 'image'", which sent the search to capacity and
|
|
334
|
+
# routing while the real answer was a missing import on a node that was up and connected.
|
|
335
|
+
#
|
|
336
|
+
# Declared-but-unavailable now degrades to TEXT, which makes the gate's refusal honest and
|
|
337
|
+
# immediate: "no node in the network can generate 'image' yet" is true and actionable,
|
|
338
|
+
# where a wasted dispatch is neither.
|
|
339
|
+
for _mod, _mod_name in (("image", "imagegen"), ("audio", "audiogen"), ("video", "videogen")):
|
|
340
|
+
if _mod not in self.output_modalities:
|
|
341
|
+
continue
|
|
342
|
+
try:
|
|
343
|
+
_gen = __import__(f"{__package__}.{_mod_name}", fromlist=[_mod_name])
|
|
344
|
+
if _gen.available():
|
|
345
|
+
continue
|
|
346
|
+
_why = "its generator reports unavailable"
|
|
347
|
+
except Exception as _e:
|
|
348
|
+
_why = f"{type(_e).__name__}: {_e}"
|
|
349
|
+
self.output_modalities.remove(_mod)
|
|
350
|
+
print(f"[{self.name}] NOT advertising '{_mod}' — {_why}. "
|
|
351
|
+
f"The operator asked for it in GEN_MODALITIES; this node cannot serve it, and "
|
|
352
|
+
f"advertising it would turn every such job into a wasted dispatch.")
|
|
353
|
+
|
|
354
|
+
# Arm the engine guard for THIS machine: from here on a job may only name a
|
|
355
|
+
# model this node advertises. The engine has no authentication and treats an
|
|
356
|
+
# unknown model as one to go and download, so an unclamped model string was a
|
|
357
|
+
# remote-controlled disk/bandwidth spend on the owner's hardware.
|
|
358
|
+
#
|
|
359
|
+
# REFUSE TO START UNARMED. `arm_models([])` (or `[""]`) leaves the allowlist
|
|
360
|
+
# None, which makes `check_model` a process-wide PASS-THROUGH — the guard
|
|
361
|
+
# silently disarms itself and every model name a job sends reaches the engine
|
|
362
|
+
# verbatim. That happened for real with `--models` unset and a set-but-EMPTY
|
|
363
|
+
# $FRUTEBOX_OLLAMA_MODEL, because the default only applies to a *missing* var.
|
|
364
|
+
# A node with nothing to serve is useless anyway, so fail loudly at startup
|
|
365
|
+
# rather than run wide open.
|
|
366
|
+
serves = [m for m in [*self.models, inference.DEFAULT_MODEL] if str(m or "").strip()]
|
|
367
|
+
if not serves:
|
|
368
|
+
raise SystemExit(
|
|
369
|
+
"frutebox node: no model to serve — pass --models, or set "
|
|
370
|
+
"$FRUTEBOX_OLLAMA_MODEL to a model this machine actually has. "
|
|
371
|
+
"(Refusing to start: an empty allowlist would disarm the engine guard "
|
|
372
|
+
"and let any job name any model for the engine to download.)")
|
|
373
|
+
# The embedding model is armed too: `inference.embed()` goes through the same
|
|
374
|
+
# guard, so retrieval would otherwise be refused on every node.
|
|
375
|
+
engine_guard.arm_models([*serves, inference.EMBED_MODEL])
|
|
376
|
+
if not engine_guard.models_armed(): # belt to the check above's braces
|
|
377
|
+
raise SystemExit("frutebox node: engine guard failed to arm — refusing to start")
|
|
378
|
+
|
|
379
|
+
# Placement trust label, assigned by the OPERATOR and delivered by the gateway
|
|
380
|
+
# in the `registered` frame — never self-declared. Gates node-side tool
|
|
381
|
+
# execution (which is additionally off by default; see engine_guard).
|
|
382
|
+
self.trust = "untrusted"
|
|
383
|
+
|
|
384
|
+
self.active = 0
|
|
385
|
+
self.rejected_jobs = 0
|
|
386
|
+
# Job ids the gateway asked to cancel mid-stream (§5A live steering / client stop).
|
|
387
|
+
# A running stream job checks this each delta and stops — freeing the GPU and
|
|
388
|
+
# halting billing at the partial. The receive loop adds ids; the job clears them.
|
|
389
|
+
#
|
|
390
|
+
# A cancel is only ever recorded for a job that is IN FLIGHT right now (see
|
|
391
|
+
# `_inflight`). Recording any id the gateway names was an unbounded, never-swept
|
|
392
|
+
# set — and a cancel sent *before* the matching job frame silently killed that
|
|
393
|
+
# job the moment it started. Bounded by max_concurrency by construction.
|
|
394
|
+
self._cancel_jobs: set = set()
|
|
395
|
+
# Job ids this node has taken a slot for and not yet finished.
|
|
396
|
+
self._inflight: set = set()
|
|
397
|
+
#: Registered sockets per gateway url. A rotated-out socket must NOT trigger a reconnect
|
|
398
|
+
#: while its replacement is already holding the link — that would turn make-before-break
|
|
399
|
+
#: into two sockets fighting for one node id, which the gateway refuses as a duplicate.
|
|
400
|
+
self._live_sockets: dict = {}
|
|
401
|
+
#: Replacement-socket tasks, held so they are not garbage collected mid-flight.
|
|
402
|
+
self._rotations: set = set()
|
|
403
|
+
# PER-STREAM REPLY SESSIONS (file 22-A1, phase two). One `pqctransport.Session` per job
|
|
404
|
+
# the gateway asked for a sealed answer on, keyed by job id and dropped when the job
|
|
405
|
+
# ends. One handshake per stream, AEAD per frame — the owner's ruling on the measured
|
|
406
|
+
# per-chunk cost. Bounded by max_concurrency by construction, like `_cancel_jobs`.
|
|
407
|
+
self._reply_sessions: dict = {}
|
|
408
|
+
self.recent_ms: Deque[float] = deque(maxlen=20)
|
|
409
|
+
self._send_lock = asyncio.Lock()
|
|
410
|
+
self._stop = False
|
|
411
|
+
|
|
412
|
+
# Live stats for the local dashboard.
|
|
413
|
+
self.gui_port = int(getattr(args, "gui_port", 0) or 0)
|
|
414
|
+
self.connected = False
|
|
415
|
+
self.jobs_served = 0
|
|
416
|
+
self.tokens_served = 0
|
|
417
|
+
#: Per-job token counts, paired with `recent_ms`, so throughput is measured and
|
|
418
|
+
#: never estimated (file 16 mock audit 2026-08-31).
|
|
419
|
+
self.recent_tokens: Deque[int] = deque(maxlen=20)
|
|
420
|
+
self.started_at = time.time()
|
|
421
|
+
|
|
422
|
+
# --- helpers ----------------------------------------------------------
|
|
423
|
+
@property
|
|
424
|
+
def load(self) -> float:
|
|
425
|
+
return min(1.0, self.active / max(1, self.max_concurrency))
|
|
426
|
+
|
|
427
|
+
def _record_job(self, tokens: int) -> None:
|
|
428
|
+
self.jobs_served += 1
|
|
429
|
+
n = int(tokens or 0)
|
|
430
|
+
self.tokens_served += n
|
|
431
|
+
# Paired with the latency sample recorded for the same job, so `measured_tps` is
|
|
432
|
+
# arithmetic over real observations rather than a table of guesses.
|
|
433
|
+
self.recent_tokens.append(n)
|
|
434
|
+
|
|
435
|
+
# --- concurrency cap --------------------------------------------------
|
|
436
|
+
# Taken/released in the RECEIVE LOOP, synchronously, before the task is created:
|
|
437
|
+
# counting inside the task leaves a window where N frames all pass the check.
|
|
438
|
+
def _acquire_slot(self) -> bool:
|
|
439
|
+
if self.active >= self.max_concurrency:
|
|
440
|
+
return False
|
|
441
|
+
self.active += 1
|
|
442
|
+
return True
|
|
443
|
+
|
|
444
|
+
def _release_slot(self) -> None:
|
|
445
|
+
self.active = max(0, self.active - 1)
|
|
446
|
+
|
|
447
|
+
@property
|
|
448
|
+
def estimated_earnings(self) -> float:
|
|
449
|
+
# Illustrative: the node's declared cost per 1k tokens for the work done.
|
|
450
|
+
return round(self.tokens_served / 1000.0 * self.cost_per_1k, 2)
|
|
451
|
+
|
|
452
|
+
def stats(self) -> dict:
|
|
453
|
+
hw = (f"{self.cpu_cores} CPU cores · {self.memory_gb:.0f}GB RAM"
|
|
454
|
+
if self.compute_type == "cpu"
|
|
455
|
+
else f"GPU · {self.memory_gb:.0f}GB")
|
|
456
|
+
return {
|
|
457
|
+
"name": self.name,
|
|
458
|
+
"hardware": hw,
|
|
459
|
+
"region": self.region,
|
|
460
|
+
"type": "CPU" if self.compute_type == "cpu" else "GPU",
|
|
461
|
+
"load": round(self.load, 3),
|
|
462
|
+
"active": self.active,
|
|
463
|
+
"jobs": self.jobs_served,
|
|
464
|
+
"tokens": self.tokens_served,
|
|
465
|
+
"earnings": self.estimated_earnings,
|
|
466
|
+
"uptime": time.time() - self.started_at,
|
|
467
|
+
"connected": self.connected,
|
|
468
|
+
# Owner-visible proof the limits are real: jobs this machine refused
|
|
469
|
+
# because it was already at its own concurrency cap.
|
|
470
|
+
"rejected": self.rejected_jobs,
|
|
471
|
+
"concurrency_cap": self.max_concurrency,
|
|
472
|
+
"trust": self.trust,
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
def stop(self) -> None:
|
|
476
|
+
"""The dashboard kill switch: stop sharing and let the process exit."""
|
|
477
|
+
self._stop = True
|
|
478
|
+
self.connected = False
|
|
479
|
+
|
|
480
|
+
@property
|
|
481
|
+
def avg_latency_ms(self) -> float:
|
|
482
|
+
return sum(self.recent_ms) / len(self.recent_ms) if self.recent_ms else 250.0
|
|
483
|
+
|
|
484
|
+
@property
|
|
485
|
+
def measured_tps(self) -> float:
|
|
486
|
+
"""FILE 16/09 — REAL measured throughput: tokens actually produced divided by the
|
|
487
|
+
time actually spent producing them. 0.0 until this node has served real work —
|
|
488
|
+
the control plane then refuses to score it rather than inventing a number, which
|
|
489
|
+
is exactly what the removed `simulateBenchmark` used to do."""
|
|
490
|
+
if not self.recent_ms or not self.recent_tokens:
|
|
491
|
+
return 0.0
|
|
492
|
+
total_ms = sum(self.recent_ms)
|
|
493
|
+
total_tokens = sum(self.recent_tokens)
|
|
494
|
+
if total_ms <= 0 or total_tokens <= 0:
|
|
495
|
+
return 0.0
|
|
496
|
+
return round(total_tokens / (total_ms / 1000.0), 2)
|
|
497
|
+
|
|
498
|
+
async def _send(self, ws, msg: dict) -> None:
|
|
499
|
+
async with self._send_lock:
|
|
500
|
+
await ws.send(json.dumps(msg))
|
|
501
|
+
|
|
502
|
+
def _pqc_keys(self):
|
|
503
|
+
"""This node's EPHEMERAL hybrid keypair for the gateway->node hop (file 22-C).
|
|
504
|
+
|
|
505
|
+
Generated once per process and never written to disk: forward secrecy means a node that
|
|
506
|
+
is later compromised cannot decrypt traffic recorded before the compromise. A node
|
|
507
|
+
without a PQC runtime simply publishes no key and the gateway decides what to do about
|
|
508
|
+
that (file 22-B5 — fail closed once STEVAI_PQC_REQUIRE is armed).
|
|
509
|
+
"""
|
|
510
|
+
if getattr(self, "_pqc", None) is None:
|
|
511
|
+
try:
|
|
512
|
+
from . import pqctransport
|
|
513
|
+
self._pqc = pqctransport.generate()
|
|
514
|
+
except Exception as e:
|
|
515
|
+
print(f"[{self.name}] no post-quantum transport available: {type(e).__name__}")
|
|
516
|
+
self._pqc = False
|
|
517
|
+
return self._pqc or None
|
|
518
|
+
|
|
519
|
+
def _open_job(self, job: dict) -> dict:
|
|
520
|
+
"""Decrypt a sealed job payload at the node boundary (file 22-A3).
|
|
521
|
+
|
|
522
|
+
This IS the authorized decryption point: the node is where the model runs, so it is the
|
|
523
|
+
first and only place the prompt may exist in plaintext on this machine.
|
|
524
|
+
"""
|
|
525
|
+
sealed = job.get("sealed")
|
|
526
|
+
if not sealed:
|
|
527
|
+
return job
|
|
528
|
+
keys = self._pqc_keys()
|
|
529
|
+
if keys is None:
|
|
530
|
+
raise RuntimeError("received a sealed job but this node has no PQC transport")
|
|
531
|
+
from . import pqctransport
|
|
532
|
+
import json as _json
|
|
533
|
+
opened = pqctransport.open_sealed(pqctransport.from_wire(sealed), keys)
|
|
534
|
+
payload = _json.loads(opened.decode())
|
|
535
|
+
# Metadata stays as the gateway sent it; content comes from the sealed half only.
|
|
536
|
+
return {**{k: v for k, v in job.items() if k != "sealed"}, **payload}
|
|
537
|
+
|
|
538
|
+
def _register_msg(self, rotate: bool = False) -> dict:
|
|
539
|
+
return {
|
|
540
|
+
# MAKE-BEFORE-BREAK (F-20.3). Set only on a socket opened to REPLACE this node's own
|
|
541
|
+
# live one. It is a request, not a right: the gateway does not act on it until the
|
|
542
|
+
# credential has proved this is the same node, because node ids are public.
|
|
543
|
+
"rotate": bool(rotate),
|
|
544
|
+
# File 22-C: the gateway seals this node's jobs to this key. Published as raw bytes
|
|
545
|
+
# in hex so it survives JSON; absent when the node has no PQC runtime.
|
|
546
|
+
"pqc_public": (self._pqc_keys().public().encode().hex()
|
|
547
|
+
if self._pqc_keys() else ""),
|
|
548
|
+
# File 22-A1: this build opens sealed `embed` frames and seals the vectors back.
|
|
549
|
+
# The key above does not say that on its own — nodes have published keys since 22-C
|
|
550
|
+
# while unsealing ran on the `job` path only — so the gateway waits to be TOLD
|
|
551
|
+
# before it seals a document to a node that might not be able to read it.
|
|
552
|
+
"sealed_embed": True,
|
|
553
|
+
"type": "register",
|
|
554
|
+
"id": self.id,
|
|
555
|
+
"name": self.name,
|
|
556
|
+
"region": self.region,
|
|
557
|
+
"compute_type": self.compute_type,
|
|
558
|
+
"memory_gb": self.memory_gb,
|
|
559
|
+
# The directory tiers a node on vram_gb (GPU) and budgets CPU-RAM
|
|
560
|
+
# pooling on ram_gb, so report the right one for this hardware class.
|
|
561
|
+
"vram_gb": self.memory_gb if self.compute_type == "gpu" else 0.0,
|
|
562
|
+
"ram_gb": self.memory_gb if self.compute_type == "cpu" else 0.0,
|
|
563
|
+
"cpu_cores": self.cpu_cores,
|
|
564
|
+
"models": self.models,
|
|
565
|
+
"cost_per_1k": self.cost_per_1k,
|
|
566
|
+
"max_concurrency": self.max_concurrency,
|
|
567
|
+
"latency_hint_ms": self.avg_latency_ms,
|
|
568
|
+
# Measured, not simulated: 0.0 until this node has actually served work.
|
|
569
|
+
"measured_tps": self.measured_tps,
|
|
570
|
+
# Output modalities this node can actually generate. Ollama chat models
|
|
571
|
+
# produce text; image/audio/video generation lives on capable nodes
|
|
572
|
+
# (Tier A/S) that advertise those here.
|
|
573
|
+
"output_modalities": self.output_modalities,
|
|
574
|
+
# Software-integrity attestation: a digest of this agent's own code,
|
|
575
|
+
# checked by the gateway against the operator's published digests.
|
|
576
|
+
# Telemetry once a credential is present — the signature is the anchor.
|
|
577
|
+
"agent_digest": attest.agent_digest(),
|
|
578
|
+
# Signed admission credential, if the owner was issued one. Empty string
|
|
579
|
+
# = legacy node: the gateway then falls back to digest + approval list.
|
|
580
|
+
# Re-sent on every reconnect (see run()), so restarting the process is
|
|
581
|
+
# all it takes to pick up a refreshed credential before this one lapses.
|
|
582
|
+
"credential": self.credential,
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
# --- tasks ------------------------------------------------------------
|
|
586
|
+
async def _heartbeat_loop(self, ws) -> None:
|
|
587
|
+
while not self._stop:
|
|
588
|
+
await self._send(ws, {
|
|
589
|
+
"type": "heartbeat",
|
|
590
|
+
"load": round(self.load, 3),
|
|
591
|
+
"latency_ms": round(self.avg_latency_ms, 1),
|
|
592
|
+
"active": self.active,
|
|
593
|
+
})
|
|
594
|
+
await asyncio.sleep(CONFIG.heartbeat_seconds)
|
|
595
|
+
# Kill switch was pressed — drop the connection so the node stops serving.
|
|
596
|
+
try:
|
|
597
|
+
await ws.close()
|
|
598
|
+
except Exception:
|
|
599
|
+
pass
|
|
600
|
+
|
|
601
|
+
def _seal_reply(self, reply: dict, target, job_id) -> dict:
|
|
602
|
+
"""Seal the content half of a reply back to the gateway that asked for it (22-A1).
|
|
603
|
+
|
|
604
|
+
THE ONE PLACE A CONTENT-BEARING ANSWER LEAVES THIS NODE. Every reply frame that carries
|
|
605
|
+
anything the user said or the model wrote goes through here, so the policy — which fields
|
|
606
|
+
move into the sealed half, what stays readable, and what happens when sealing fails —
|
|
607
|
+
lives in one function rather than once per call site. `tests/test_transport_seal_guard.py`
|
|
608
|
+
is built on that: it reads the node's syntax tree and fails on an answer frame that
|
|
609
|
+
reaches the socket without passing through this.
|
|
610
|
+
|
|
611
|
+
`target` says HOW, and there are exactly two forms:
|
|
612
|
+
|
|
613
|
+
* A HEX PUBLIC KEY — the gateway's ephemeral reply key, as `reply_to` carries it. One
|
|
614
|
+
fresh X25519 ephemeral and one fresh ML-KEM encapsulation for this frame. Right for a
|
|
615
|
+
one-shot answer: `/v1/embeddings` sends a single reply, and a handshake amortised
|
|
616
|
+
over one 4 MB vector batch costs nothing worth measuring.
|
|
617
|
+
|
|
618
|
+
* A `pqctransport.Session` — one handshake for the whole stream, AEAD per frame. Right
|
|
619
|
+
for a completion, which is delivered as hundreds of `chunk` frames and then a
|
|
620
|
+
`result`: measured, per-frame sealing cost +1703 B and ~0.8 ms PER CHUNK, so a
|
|
621
|
+
500-token answer paid 400 ms of CPU and 831 KiB of handshake to protect 2 KiB of
|
|
622
|
+
text. The owner ruled the session key for exactly that reason, and the guarantee is
|
|
623
|
+
unchanged — every one of those handshakes encapsulated to the SAME gateway key, and
|
|
624
|
+
forward secrecy comes from that key's lifetime rather than from how many times
|
|
625
|
+
anyone encapsulates to it.
|
|
626
|
+
|
|
627
|
+
RAISES on any failure, and the caller turns that into an error frame. There is
|
|
628
|
+
deliberately no "send it in the clear instead" branch: the gateway only asks for a
|
|
629
|
+
sealed reply on a hop it sealed, and answering that in plaintext would be a downgrade
|
|
630
|
+
anyone on the path could force by stripping a field.
|
|
631
|
+
"""
|
|
632
|
+
from . import pqctransport
|
|
633
|
+
payload = {k: reply[k] for k in _SEALED_REPLY_FIELDS if k in reply}
|
|
634
|
+
md = {"job_id": job_id} if isinstance(job_id, (str, int)) else {}
|
|
635
|
+
clear = {k: v for k, v in reply.items() if k not in _SEALED_REPLY_FIELDS}
|
|
636
|
+
if isinstance(target, pqctransport.Session):
|
|
637
|
+
frame = target.seal_frame(json.dumps(payload).encode(), metadata=md)
|
|
638
|
+
# The handshake rides on the session's FIRST frame and on no other. That is where
|
|
639
|
+
# the saving is: frame 0 costs what a one-shot seal used to cost, and every frame
|
|
640
|
+
# after it costs an AEAD tag.
|
|
641
|
+
clear["sealed"] = pqctransport.session_wire_format(
|
|
642
|
+
frame, handshake=target.handshake if frame.seq == 0 else None)
|
|
643
|
+
return clear
|
|
644
|
+
pub = pqctransport.HybridPublic.decode(bytes.fromhex(target))
|
|
645
|
+
sealed = pqctransport.seal(json.dumps(payload).encode(), pub, metadata=md)
|
|
646
|
+
clear["sealed"] = pqctransport.wire_format(sealed)
|
|
647
|
+
return clear
|
|
648
|
+
|
|
649
|
+
def _begin_reply_session(self, job_id, reply_to: str):
|
|
650
|
+
"""Open the per-stream session this job's answer comes home on, or RAISE.
|
|
651
|
+
|
|
652
|
+
The gateway's ephemeral public key arrives as `reply_to` INSIDE the sealed half of the
|
|
653
|
+
job — never beside it — and that is what makes it trustworthy: in the clear it could be
|
|
654
|
+
swapped on the path for an attacker's key, and this node would then seal the user's
|
|
655
|
+
answer to the attacker. AEAD-authenticated, it cannot be touched.
|
|
656
|
+
|
|
657
|
+
FAILS CLOSED. A node that cannot open a session for a job whose request asked for one
|
|
658
|
+
does not fall back to answering in the clear; it errors, and the gateway re-plans. The
|
|
659
|
+
clear answer is the thing phase two exists to stop sending.
|
|
660
|
+
"""
|
|
661
|
+
from . import pqctransport
|
|
662
|
+
pub = pqctransport.HybridPublic.decode(bytes.fromhex(reply_to))
|
|
663
|
+
session, _handshake = pqctransport.begin_session(pub)
|
|
664
|
+
self._reply_sessions[job_id] = session
|
|
665
|
+
return session
|
|
666
|
+
|
|
667
|
+
async def _handle_embed(self, ws, msg: dict) -> None:
|
|
668
|
+
"""Embed texts for the network's persistent RAG index (§5.2).
|
|
669
|
+
|
|
670
|
+
A separate frame type from `job` on purpose. An embedding is not a generation: it
|
|
671
|
+
runs a different model, produces vectors rather than tokens, and must NOT be
|
|
672
|
+
metered as a chat turn or a user would be billed twice for one document. It still
|
|
673
|
+
takes a concurrency slot, because it is still real work on the owner's machine.
|
|
674
|
+
|
|
675
|
+
A node whose engine has no embed endpoint (a llama.cpp chat node) answers with an
|
|
676
|
+
error, and the gateway re-plans onto one that can — the same shape as any other
|
|
677
|
+
capability the fleet is uneven about.
|
|
678
|
+
"""
|
|
679
|
+
job_id = msg.get("id")
|
|
680
|
+
try:
|
|
681
|
+
was_sealed = bool(msg.get("sealed"))
|
|
682
|
+
# File 22-A3: the node boundary is the authorized decryption point, for an embed
|
|
683
|
+
# exactly as for a job — the documents in `texts` are content. `_open_job` RAISES
|
|
684
|
+
# when a sealed frame cannot be opened, and that is the whole point of calling it
|
|
685
|
+
# here: the old code read `texts` straight off the frame, so a sealed frame would
|
|
686
|
+
# have yielded no texts at all and been embedded as an empty batch. The gateway
|
|
687
|
+
# would have got a successful-looking reply for a document that was never indexed.
|
|
688
|
+
msg = self._open_job(msg)
|
|
689
|
+
texts = msg.get("texts") or msg.get("input") or []
|
|
690
|
+
if not isinstance(texts, list):
|
|
691
|
+
raise ValueError("embed frame needs a list of texts")
|
|
692
|
+
# Bound the batch: an unbounded list is an easy way to pin someone's CPU.
|
|
693
|
+
texts = [str(t or "") for t in texts][:512]
|
|
694
|
+
if not texts:
|
|
695
|
+
# AN EMPTY BATCH IS NEVER AN ANSWER. Whether the frame arrived empty or arrived
|
|
696
|
+
# in a shape this node could not read, embedding nothing and replying "fine"
|
|
697
|
+
# loses the document silently. Say so instead and let the gateway re-plan.
|
|
698
|
+
raise ValueError("embed frame carried no texts" +
|
|
699
|
+
(" after opening its sealed half" if was_sealed else ""))
|
|
700
|
+
vectors = await inference.embed(texts)
|
|
701
|
+
if len(vectors) != len(texts):
|
|
702
|
+
# The engine answered, but not for every chunk. The caller indexes vectors
|
|
703
|
+
# positionally, so a short list is a document indexed against the wrong text —
|
|
704
|
+
# and a silently partial index is the same defect as an empty one.
|
|
705
|
+
raise ValueError(f"engine returned {len(vectors)} vectors for {len(texts)} texts")
|
|
706
|
+
reply = {"type": "embeddings", "id": job_id,
|
|
707
|
+
"embeddings": vectors, "model": inference.EMBED_MODEL}
|
|
708
|
+
# The gateway put its own public key in the sealed half when it wants the answer
|
|
709
|
+
# sealed too. Absent = a gateway or a hop that could not seal this request either,
|
|
710
|
+
# so the clear reply is the one it is already expecting.
|
|
711
|
+
reply_to = msg.get("reply_to") or ""
|
|
712
|
+
if reply_to:
|
|
713
|
+
reply = self._seal_reply(reply, str(reply_to), job_id)
|
|
714
|
+
await self._send(ws, reply)
|
|
715
|
+
except BaseException as e:
|
|
716
|
+
# THE DETAIL STAYS ON THIS MACHINE. `{e}` used to go on the wire, and an
|
|
717
|
+
# exception's text routinely quotes the input that produced it — a JSON error
|
|
718
|
+
# quotes the offending fragment, a tokeniser error quotes the text it choked on.
|
|
719
|
+
# That is the model-name leak wearing a different coat, and `_seal_reply` leaves
|
|
720
|
+
# `message` in the CLEAR by construction, so it is plaintext on the wire. The
|
|
721
|
+
# class name says what kind of failure it was; the owner's own log says the rest.
|
|
722
|
+
print(f"[{self.name}] embed job {job_id} failed: {type(e).__name__}: {e}")
|
|
723
|
+
await self._send(ws, {"type": "error", "id": job_id,
|
|
724
|
+
"message": f"embed failed: {type(e).__name__}"})
|
|
725
|
+
finally:
|
|
726
|
+
self._inflight.discard(job_id)
|
|
727
|
+
self._release_slot()
|
|
728
|
+
|
|
729
|
+
def _can_serve_model(self, model: str) -> bool:
|
|
730
|
+
"""Do we actually run this model? Compared across catalog-id and engine-tag spellings,
|
|
731
|
+
the same equivalence routing uses — a node advertising "qwen3:4b" must recognise a job
|
|
732
|
+
asking for "qwen3-4b", or it silently never races for work it can do."""
|
|
733
|
+
if not model or model == "stevai-default":
|
|
734
|
+
return True
|
|
735
|
+
mine = {str(m).strip().lower() for m in (self.models or []) if str(m).strip()}
|
|
736
|
+
if not mine:
|
|
737
|
+
return True # advertises nothing = serves whatever it has
|
|
738
|
+
want = model.strip().lower()
|
|
739
|
+
forms = {want, want.replace("-", ":"), want.replace(":", "-")}
|
|
740
|
+
return bool(forms & mine) or any(m.split(":")[0] == want.split("-")[0] for m in mine)
|
|
741
|
+
|
|
742
|
+
async def _maybe_claim(self, ws, offer: dict) -> None:
|
|
743
|
+
"""Race for a posted job — or decline by staying silent.
|
|
744
|
+
|
|
745
|
+
A busy node simply does not claim. That is the self-deflection the design asks for, and
|
|
746
|
+
it is more truthful than any load number we could publish: we know our own free slots
|
|
747
|
+
at this instant.
|
|
748
|
+
|
|
749
|
+
When we cannot run the model but CAN still take work, we claim as a BACKSTOP: someone
|
|
750
|
+
has to catch the job so it never falls on the floor, and the gateway will bring the
|
|
751
|
+
right model up and hand over. A backstop claim never outbids a node that actually runs
|
|
752
|
+
the model — the gateway enforces that ordering.
|
|
753
|
+
"""
|
|
754
|
+
job_id = offer.get("id")
|
|
755
|
+
if not isinstance(job_id, (str, int)) or job_id == "":
|
|
756
|
+
return
|
|
757
|
+
# `paused` is a DESKTOP-node concept; the Python node has no such attribute. Reading it
|
|
758
|
+
# directly raised AttributeError here, the async task swallowed it, and the node
|
|
759
|
+
# silently never claimed — every live race found nobody while every unit test passed,
|
|
760
|
+
# because the tests set the attribute by hand on a stub.
|
|
761
|
+
if getattr(self, "paused", False) or self.active >= self.max_concurrency:
|
|
762
|
+
return # busy or paused → self-deflect, silently
|
|
763
|
+
model = str(offer.get("model") or "")
|
|
764
|
+
serves = self._can_serve_model(model)
|
|
765
|
+
if not serves and not bool(offer.get("allow_backstop")):
|
|
766
|
+
return
|
|
767
|
+
await self._send(ws, {"type": "claim", "id": job_id, "backstop": not serves,
|
|
768
|
+
"price_quote": self._price_quote()})
|
|
769
|
+
|
|
770
|
+
#: Rough wattage by VRAM class, mirroring the settlement tiers in `economics`. A node that
|
|
771
|
+
#: has been benchmarked can publish its real draw instead (STEVAI_NODE_POWER_W).
|
|
772
|
+
_TIER_WATTS = ((16, 60.0), (48, 200.0), (80, 350.0))
|
|
773
|
+
|
|
774
|
+
def _price_quote(self) -> float:
|
|
775
|
+
"""What this node asks to serve one hour of work, in EUR (file 02-B3).
|
|
776
|
+
|
|
777
|
+
The gateway keeps the K CHEAPEST bids and then draws uniformly among them, so this
|
|
778
|
+
number decides whether the node is in the running — which is exactly why the node
|
|
779
|
+
computes it from its OWN power draw and local tariff rather than the gateway guessing.
|
|
780
|
+
An operator on cheap night-rate power should be able to underbid one on expensive
|
|
781
|
+
daytime power, and only the node knows that.
|
|
782
|
+
|
|
783
|
+
Never raises: a node that cannot price itself still races, and is simply ranked as
|
|
784
|
+
unpriced (last) rather than dropped from the fleet.
|
|
785
|
+
"""
|
|
786
|
+
try:
|
|
787
|
+
explicit = envvar("NODE_PRICE_EUR_PER_HR", "").strip()
|
|
788
|
+
if explicit:
|
|
789
|
+
return max(0.0, float(explicit))
|
|
790
|
+
|
|
791
|
+
watts = float(envvar("NODE_POWER_W", "") or 0.0)
|
|
792
|
+
if watts <= 0:
|
|
793
|
+
vram = float(getattr(self, "memory_gb", 0) or 0)
|
|
794
|
+
watts = 700.0
|
|
795
|
+
for limit, w in self._TIER_WATTS:
|
|
796
|
+
if vram < limit:
|
|
797
|
+
watts = w
|
|
798
|
+
break
|
|
799
|
+
|
|
800
|
+
from datetime import datetime, timezone
|
|
801
|
+
from .economics import rates_for
|
|
802
|
+
hourly = rates_for(self.region).get("per_kwh_by_hour") or [0.20] * 24
|
|
803
|
+
per_kwh = float(hourly[datetime.now(tz=timezone.utc).hour % len(hourly)])
|
|
804
|
+
return round((watts / 1000.0) * per_kwh, 6)
|
|
805
|
+
except Exception:
|
|
806
|
+
return 0.0
|
|
807
|
+
|
|
808
|
+
async def _handle_job(self, ws, job: dict) -> None:
|
|
809
|
+
"""Run one job under this node's own wall clock, and report — never leave it running.
|
|
810
|
+
|
|
811
|
+
The timeout is the node's half of the amplification fix: the gateway already
|
|
812
|
+
gives up at CONFIG.job_timeout_s and re-plans onto ANOTHER node, so a job that
|
|
813
|
+
outlives that is pure burn on the volunteer's machine. Cancelling the coroutine
|
|
814
|
+
closes the engine's HTTP stream, which is what actually stops generation.
|
|
815
|
+
"""
|
|
816
|
+
job_id = job.get("id")
|
|
817
|
+
try:
|
|
818
|
+
# File 22-A3: the node boundary is the authorized decryption point. Everything
|
|
819
|
+
# before this line has only ever held ciphertext plus routing metadata.
|
|
820
|
+
job = self._open_job(job)
|
|
821
|
+
except Exception as e:
|
|
822
|
+
print(f"[{self.name}] could not open sealed job {job_id}: {type(e).__name__}")
|
|
823
|
+
await self._send(ws, {"type": "error", "id": job_id,
|
|
824
|
+
"error": "sealed payload could not be opened"})
|
|
825
|
+
# THE SLOT WAS ALREADY TAKEN, synchronously, by the receive loop — and this branch
|
|
826
|
+
# used to `return` straight past the release. A node that could not open four sealed
|
|
827
|
+
# jobs sat pinned at load 1.0, still heartbeating as alive, exactly as the
|
|
828
|
+
# `_handle_shard_job` docstring describes for its own version of this bug. An error
|
|
829
|
+
# reply is not an exit from the slot's lifetime.
|
|
830
|
+
self._inflight.discard(job_id)
|
|
831
|
+
self._release_slot()
|
|
832
|
+
return
|
|
833
|
+
try:
|
|
834
|
+
# PHASE TWO of the result-seal cutover. `reply_to` came out of the SEALED half just
|
|
835
|
+
# above, so it is the gateway's own key and not one a relay substituted. Its
|
|
836
|
+
# presence is the request: seal the answer home. Its absence means the gateway could
|
|
837
|
+
# not seal this hop either (a keyless node, a build that cannot), and the clear
|
|
838
|
+
# answer is the one it is already expecting — `ACCEPT_UNSEALED_RESULT` is still True
|
|
839
|
+
# for exactly the length of this rollout.
|
|
840
|
+
#
|
|
841
|
+
# Opened BEFORE the job runs, so a node that cannot seal the answer burns none of
|
|
842
|
+
# the owner's compute discovering it at the end.
|
|
843
|
+
reply_to = job.get("reply_to") or ""
|
|
844
|
+
if reply_to:
|
|
845
|
+
self._begin_reply_session(job_id, str(reply_to))
|
|
846
|
+
except Exception as e:
|
|
847
|
+
print(f"[{self.name}] could not open a reply session for {job_id}: "
|
|
848
|
+
f"{type(e).__name__}")
|
|
849
|
+
await self._send(ws, {"type": "error", "id": job_id,
|
|
850
|
+
"message": (f"this node cannot seal its answer back to the "
|
|
851
|
+
f"gateway ({type(e).__name__}) and will not send "
|
|
852
|
+
f"it in the clear")})
|
|
853
|
+
self._inflight.discard(job_id)
|
|
854
|
+
self._release_slot()
|
|
855
|
+
return
|
|
856
|
+
try:
|
|
857
|
+
await asyncio.wait_for(self._run_job(ws, job), timeout=self.job_timeout_s)
|
|
858
|
+
except asyncio.TimeoutError:
|
|
859
|
+
await self._send(ws, {
|
|
860
|
+
"type": "error", "id": job_id,
|
|
861
|
+
"message": (f"job exceeded this node's {self.job_timeout_s:.0f}s limit "
|
|
862
|
+
f"and was cancelled"),
|
|
863
|
+
})
|
|
864
|
+
except Exception as e: # never let one bad job kill the node
|
|
865
|
+
# Same reason as `_handle_embed`: `{e}` put request-derived text on the wire in
|
|
866
|
+
# the clear. It was not hypothetical — `engine_guard.resolve_model` raises
|
|
867
|
+
# "model {requested!r} is not served by this node" and `_run_job` raises "this
|
|
868
|
+
# node does not generate {modality!r}", so the caller's own model string and
|
|
869
|
+
# modality came straight back out through this frame.
|
|
870
|
+
print(f"[{self.name}] job {job_id} failed: {type(e).__name__}: {e}")
|
|
871
|
+
await self._send(ws, {
|
|
872
|
+
"type": "error",
|
|
873
|
+
"id": job_id,
|
|
874
|
+
"message": f"job failed: {type(e).__name__}",
|
|
875
|
+
})
|
|
876
|
+
finally:
|
|
877
|
+
self._cancel_jobs.discard(job_id)
|
|
878
|
+
self._inflight.discard(job_id)
|
|
879
|
+
self._reply_sessions.pop(job_id, None)
|
|
880
|
+
self._release_slot()
|
|
881
|
+
|
|
882
|
+
async def _run_job(self, ws, job: dict) -> None:
|
|
883
|
+
"""The actual work. Raises on any failure — `_handle_job` owns reporting,
|
|
884
|
+
the clock and the concurrency slot.
|
|
885
|
+
|
|
886
|
+
PHASE TWO: every answer frame below goes through `_seal_reply` when the gateway asked
|
|
887
|
+
for a sealed answer, and `reply_session` is what that request left behind. One session
|
|
888
|
+
for this whole job, so a stream pays one handshake rather than one per token delta.
|
|
889
|
+
`None` means the gateway did not ask — a hop neither end could seal — and the frame goes
|
|
890
|
+
home exactly as it did before, which is what keeps this a phase and not a flag day.
|
|
891
|
+
"""
|
|
892
|
+
job_id = job["id"]
|
|
893
|
+
reply_session = self._reply_sessions.get(job_id)
|
|
894
|
+
start = time.time()
|
|
895
|
+
modality = (job.get("profile") or {}).get("modality") or job.get("modality") or "text"
|
|
896
|
+
# THE JOB DOES NOT GET TO PICK WHAT THIS MACHINE DOES. `modality` is
|
|
897
|
+
# attacker-controlled data off the wire, and the gateway only checks that
|
|
898
|
+
# *some* node in the region is capable — it does not filter the node it picked.
|
|
899
|
+
# Without this, a text-only laptop could be told to load SDXL / a video
|
|
900
|
+
# diffusion model and spend minutes of GPU it never advertised.
|
|
901
|
+
if modality != "text" and modality not in self.output_modalities:
|
|
902
|
+
raise engine_guard.EngineGuardError(
|
|
903
|
+
f"this node does not generate {modality!r} "
|
|
904
|
+
f"(serves: {', '.join(self.output_modalities)})")
|
|
905
|
+
if modality in ("image", "audio", "video"):
|
|
906
|
+
import base64
|
|
907
|
+
prompt = ""
|
|
908
|
+
for m in reversed(job.get("messages", [])):
|
|
909
|
+
if m.get("role") == "user":
|
|
910
|
+
c = m.get("content", "")
|
|
911
|
+
prompt = c if isinstance(c, str) else " ".join(p.get("text", "") for p in c if isinstance(p, dict))
|
|
912
|
+
break
|
|
913
|
+
# Media generation is SYNCHRONOUS, CPU/GPU-bound torch work that runs for
|
|
914
|
+
# seconds to minutes. Called directly it froze the whole event loop: the
|
|
915
|
+
# heartbeat stopped (so the gateway marked this node stale mid-job), every
|
|
916
|
+
# other concurrent job on the machine stalled, and — because a blocked loop
|
|
917
|
+
# can never run the timeout callback — `_handle_job`'s wall-clock ceiling
|
|
918
|
+
# could not fire. Off the loop it goes.
|
|
919
|
+
# Honest scope: the ceiling can now *report* and free the slot, but it
|
|
920
|
+
# cannot kill the thread; truly stopping torch mid-generation needs a
|
|
921
|
+
# child process (see engine_guard's "what is NOT contained").
|
|
922
|
+
if modality == "image":
|
|
923
|
+
from . import imagegen
|
|
924
|
+
# FILE 03-E2: the paid "enhance" conditions on the sketch the user's own device
|
|
925
|
+
# already made, rather than re-rolling from the prompt. `init` is that sketch;
|
|
926
|
+
# its absence is an ordinary text-to-image generation.
|
|
927
|
+
_init_b64 = job.get("init") or ""
|
|
928
|
+
_init = base64.b64decode(_init_b64) if _init_b64 else b""
|
|
929
|
+
_strength = float(job.get("strength") or 0.4)
|
|
930
|
+
# Name the model that ACTUALLY ran. This said "sdxl" unconditionally, which
|
|
931
|
+
# stopped being true the moment model choice moved to the registry resolver —
|
|
932
|
+
# and this string is what the artifact reply and the metering path report.
|
|
933
|
+
_img_model = imagegen.resolved_model().model_id
|
|
934
|
+
data, mime, backend = (
|
|
935
|
+
await asyncio.to_thread(imagegen.generate_image, prompt,
|
|
936
|
+
init=_init, strength=_strength),
|
|
937
|
+
"image/png", f"{_img_model}-img2img" if _init else _img_model)
|
|
938
|
+
elif modality == "audio":
|
|
939
|
+
from . import audiogen
|
|
940
|
+
# A consented voice sample rides with the job and is used for
|
|
941
|
+
# this utterance only; audiogen deletes it immediately after.
|
|
942
|
+
# It is the one place attacker-controlled BYTES reach a decoder on
|
|
943
|
+
# this machine, so it is size-capped here as well as scanned at the
|
|
944
|
+
# gateway — a node does not assume its gateway did the right thing.
|
|
945
|
+
sample_b64 = job.get("voice_sample") or ""
|
|
946
|
+
sample = engine_guard.check_voice_sample(
|
|
947
|
+
base64.b64decode(sample_b64) if sample_b64 else None)
|
|
948
|
+
data, mime, backend = (
|
|
949
|
+
await asyncio.to_thread(audiogen.generate_audio, prompt,
|
|
950
|
+
voice_sample=sample,
|
|
951
|
+
language=job.get("language", "")),
|
|
952
|
+
"audio/wav",
|
|
953
|
+
"voice-clone" if sample else "audio",
|
|
954
|
+
)
|
|
955
|
+
else:
|
|
956
|
+
from . import videogen
|
|
957
|
+
data, mime, backend = (
|
|
958
|
+
await asyncio.to_thread(videogen.generate_video, prompt),
|
|
959
|
+
"video/mp4", "video")
|
|
960
|
+
took_ms = (time.time() - start) * 1000.0
|
|
961
|
+
self.recent_ms.append(took_ms)
|
|
962
|
+
# A generated image/audio/video is the user's content in full, base64 on the frame —
|
|
963
|
+
# it is sealed like a completion, not exempted for being bytes rather than text.
|
|
964
|
+
artifact_reply = {
|
|
965
|
+
"type": "result", "id": job_id, "content": "",
|
|
966
|
+
"artifact": {"modality": modality, "mime": mime, "data_b64": base64.b64encode(data).decode()},
|
|
967
|
+
"tool_calls": [], "tokens": 0, "prompt_tokens": 0,
|
|
968
|
+
"backend": backend, "compute_ms": round(took_ms, 1),
|
|
969
|
+
}
|
|
970
|
+
if reply_session is not None:
|
|
971
|
+
artifact_reply = self._seal_reply(artifact_reply, reply_session, job_id)
|
|
972
|
+
await self._send(ws, artifact_reply)
|
|
973
|
+
return
|
|
974
|
+
if job.get("stream"):
|
|
975
|
+
# Streaming text (§5A/§5B foundation): forward the model's REASONING and
|
|
976
|
+
# CONTENT deltas as `chunk` frames as they arrive, then the aggregated
|
|
977
|
+
# `result` frame (same shape as non-stream, so the future-based path still
|
|
978
|
+
# resolves for metering/history). Dormant unless the gateway sets stream.
|
|
979
|
+
agg: dict = {}
|
|
980
|
+
partial: list = []
|
|
981
|
+
async for ev in inference.stream_turn( # dispatches: llama.cpp (Kimi) or Ollama
|
|
982
|
+
job.get("model", "stevai-default"), job.get("messages", []),
|
|
983
|
+
job.get("tools"), job.get("tool_choice"), think=job.get("think"),
|
|
984
|
+
think_effort=job.get("think_effort") or "",
|
|
985
|
+
):
|
|
986
|
+
if job_id in self._cancel_jobs:
|
|
987
|
+
# Client bailed/steered → stop compute + billing at the partial.
|
|
988
|
+
# Returning closes stream_ollama's httpx stream, halting Ollama.
|
|
989
|
+
self._cancel_jobs.discard(job_id)
|
|
990
|
+
self._record_job(inference.estimate_tokens("".join(partial)))
|
|
991
|
+
return
|
|
992
|
+
if ev["type"] in ("reasoning", "content"):
|
|
993
|
+
if ev["type"] == "content":
|
|
994
|
+
partial.append(ev["text"])
|
|
995
|
+
# THE FRAME THE WHOLE COST QUESTION TURNED ON. One of these per token
|
|
996
|
+
# delta, `text` in the clear until now, and a completion spends most of
|
|
997
|
+
# its life here. Sealed under the per-stream session key: an AEAD tag and
|
|
998
|
+
# a sequence number per chunk, and the handshake only on the first.
|
|
999
|
+
chunk_reply = {"type": "chunk", "id": job_id,
|
|
1000
|
+
"channel": ev["type"], "text": ev["text"]}
|
|
1001
|
+
if reply_session is not None:
|
|
1002
|
+
chunk_reply = self._seal_reply(chunk_reply, reply_session, job_id)
|
|
1003
|
+
await self._send(ws, chunk_reply)
|
|
1004
|
+
elif ev["type"] == "done":
|
|
1005
|
+
agg = ev
|
|
1006
|
+
took_ms = (time.time() - start) * 1000.0
|
|
1007
|
+
self.recent_ms.append(took_ms)
|
|
1008
|
+
self._record_job(int(agg.get("tokens", 0)))
|
|
1009
|
+
# The aggregate the gateway meters and stores history from. It repeats the whole
|
|
1010
|
+
# answer, so sealing the deltas and leaving this in the clear would protect nothing.
|
|
1011
|
+
stream_reply = {
|
|
1012
|
+
"type": "result", "id": job_id,
|
|
1013
|
+
"content": agg.get("content", ""),
|
|
1014
|
+
"reasoning": agg.get("reasoning", ""),
|
|
1015
|
+
"tool_calls": agg.get("tool_calls", []),
|
|
1016
|
+
"tokens": int(agg.get("tokens", 0)),
|
|
1017
|
+
"prompt_tokens": int(agg.get("prompt_tokens", 0)),
|
|
1018
|
+
"backend": agg.get("backend", ""),
|
|
1019
|
+
"compute_ms": round(took_ms, 1),
|
|
1020
|
+
}
|
|
1021
|
+
if reply_session is not None:
|
|
1022
|
+
stream_reply = self._seal_reply(stream_reply, reply_session, job_id)
|
|
1023
|
+
await self._send(ws, stream_reply)
|
|
1024
|
+
return
|
|
1025
|
+
result = await inference.run_inference(
|
|
1026
|
+
model=job.get("model", "stevai-default"),
|
|
1027
|
+
messages=job.get("messages", []),
|
|
1028
|
+
region=self.region,
|
|
1029
|
+
load=self.load,
|
|
1030
|
+
compute_type=self.compute_type,
|
|
1031
|
+
tools=job.get("tools"),
|
|
1032
|
+
tool_choice=job.get("tool_choice"),
|
|
1033
|
+
# THE NON-STREAM PATH DROPPED THIS, and `run_inference` has accepted it all along.
|
|
1034
|
+
#
|
|
1035
|
+
# `think` is the gateway's per-turn decision about whether a question is worth
|
|
1036
|
+
# reasoning over. The streaming handler forwards it (see `stream_turn` above); this
|
|
1037
|
+
# one did not, so on the JSON path the flag was computed, sealed into the job, carried
|
|
1038
|
+
# across the wire, and then dropped one line before the engine.
|
|
1039
|
+
#
|
|
1040
|
+
# MEASURED 2026-09-29 against a live node. Ollama honours it: `think:false` returned
|
|
1041
|
+
# 3 completion tokens and an empty thinking channel, while the same prompt without it
|
|
1042
|
+
# returned 134 tokens and 588 characters of reasoning. Through the gateway, a
|
|
1043
|
+
# non-streaming turn with `quality:"speed"` — which forces the hint to False on both
|
|
1044
|
+
# the old and the new code — still spent ~172 hidden tokens. That gap was this
|
|
1045
|
+
# argument.
|
|
1046
|
+
think=job.get("think"),
|
|
1047
|
+
think_effort=job.get("think_effort") or "",
|
|
1048
|
+
)
|
|
1049
|
+
took_ms = (time.time() - start) * 1000.0
|
|
1050
|
+
self.recent_ms.append(took_ms)
|
|
1051
|
+
self._record_job(result["tokens"])
|
|
1052
|
+
reply = {
|
|
1053
|
+
"type": "result",
|
|
1054
|
+
"id": job_id,
|
|
1055
|
+
"content": result["content"],
|
|
1056
|
+
# THE THINKING CHANNEL. The streaming handler above has always sent this; this frame — the
|
|
1057
|
+
# one a NON-STREAMING job gets — omitted it, so the gateway received a result with no
|
|
1058
|
+
# `reasoning` key at all (measured: keys were content/tool_calls/tokens/prompt_tokens/
|
|
1059
|
+
# backend/compute_ms/id/type). `reasoning` is in `_SEALED_REPLY_FIELDS`, so it is sealed
|
|
1060
|
+
# with the answer rather than travelling beside it in the clear.
|
|
1061
|
+
"reasoning": result.get("reasoning", ""),
|
|
1062
|
+
"tool_calls": result.get("tool_calls", []), # agentic mode passthrough
|
|
1063
|
+
"tokens": result["tokens"],
|
|
1064
|
+
"prompt_tokens": result["prompt_tokens"],
|
|
1065
|
+
"backend": result["backend"],
|
|
1066
|
+
"compute_ms": round(took_ms, 1),
|
|
1067
|
+
}
|
|
1068
|
+
if reply_session is not None:
|
|
1069
|
+
reply = self._seal_reply(reply, reply_session, job_id)
|
|
1070
|
+
await self._send(ws, reply)
|
|
1071
|
+
|
|
1072
|
+
async def _handle_shard_job(self, ws, job: dict) -> None:
|
|
1073
|
+
"""One member of a CPU pool: hold and run this node's slice of the model.
|
|
1074
|
+
|
|
1075
|
+
The pool exists when no single node has enough memory for the whole
|
|
1076
|
+
model. Each member owns a shard (a range of layers/experts); the lead
|
|
1077
|
+
(shard 0) returns the assembled text, the others return their timing so
|
|
1078
|
+
the gateway can report the collaboration honestly.
|
|
1079
|
+
"""
|
|
1080
|
+
# EVERY exit path from here MUST hit the `finally` that releases the
|
|
1081
|
+
# concurrency slot — the slot was already taken, synchronously, by the receive
|
|
1082
|
+
# loop. Reading `job["id"]` / `int(shard_index)` *above* the try meant a
|
|
1083
|
+
# `shard_job` frame with no id (or `shard_index: "NaN"`) raised before the try
|
|
1084
|
+
# was entered: the slot leaked and no error frame was sent. Four such frames
|
|
1085
|
+
# pinned a node at load 1.0, still heartbeating as alive, until the process was
|
|
1086
|
+
# restarted. So: tolerant reads, and the shape checks live inside the try.
|
|
1087
|
+
job_id = job.get("id")
|
|
1088
|
+
shard_index = 0
|
|
1089
|
+
start = time.time()
|
|
1090
|
+
try:
|
|
1091
|
+
# THE POOLED PATH OPENS ITS SEALED HALF TOO. The gateway began sealing `shard_job`
|
|
1092
|
+
# on 2026-09-24 (the `_execute_pool` bypass fix), but this handler read `messages`
|
|
1093
|
+
# RAW — so a sealed shard arrived with its content in the `sealed` blob, `messages`
|
|
1094
|
+
# defaulted to `[]`, and every pool member ran inference on an EMPTY PROMPT and
|
|
1095
|
+
# returned a confident answer to nothing. Sealing one end and not the other is the
|
|
1096
|
+
# same defect the embed frame had: silently empty input wearing the costume of
|
|
1097
|
+
# protection. `_open_job` RAISES on a sealed frame it cannot open, and the `except`
|
|
1098
|
+
# below turns that into an error frame — which is the honest outcome, not `[]`.
|
|
1099
|
+
job = self._open_job(job)
|
|
1100
|
+
shard_index = int(job.get("shard_index", 0) or 0)
|
|
1101
|
+
shard_count = int(job.get("shard_count", 1) or 1)
|
|
1102
|
+
# THE POOLED REPLY, SEALED — the other half of the fix above, and the last frame in
|
|
1103
|
+
# this file that carried a completion in the clear. `reply_to` comes out of the sealed
|
|
1104
|
+
# half just opened, so it is the gateway's own key and not one a relay substituted.
|
|
1105
|
+
#
|
|
1106
|
+
# Opened BEFORE inference, exactly as `_handle_job` does it: a node that cannot seal
|
|
1107
|
+
# its answer must discover that before it spends the owner's compute, not after. The
|
|
1108
|
+
# `except` below turns the raise into an error frame, which is the honest outcome — a
|
|
1109
|
+
# node that cannot seal does NOT fall back to answering in the clear, because the clear
|
|
1110
|
+
# answer is the thing this phase exists to stop sending.
|
|
1111
|
+
#
|
|
1112
|
+
# Non-lead shards return timing only, but they return it on the SAME frame shape, so
|
|
1113
|
+
# they go through the same policy. A shape that sometimes carries a completion is
|
|
1114
|
+
# treated as one that always does; deciding per-frame is how the single path's leak
|
|
1115
|
+
# survived as long as it did.
|
|
1116
|
+
reply_to = job.get("reply_to") or ""
|
|
1117
|
+
reply_session = (self._begin_reply_session(job_id, str(reply_to))
|
|
1118
|
+
if reply_to else None)
|
|
1119
|
+
result = await asyncio.wait_for(inference.run_inference(
|
|
1120
|
+
model=job.get("model", "stevai-default"),
|
|
1121
|
+
messages=job.get("messages", []),
|
|
1122
|
+
region=self.region,
|
|
1123
|
+
load=self.load,
|
|
1124
|
+
compute_type=self.compute_type,
|
|
1125
|
+
shard=(shard_index, shard_count),
|
|
1126
|
+
tools=job.get("tools"),
|
|
1127
|
+
tool_choice=job.get("tool_choice"),
|
|
1128
|
+
# The SHARDED path dropped it too. A shard is still a turn, and a turn the
|
|
1129
|
+
# gateway decided needs no reasoning does not acquire the need by being split
|
|
1130
|
+
# across nodes — it would just pay for the thinking once per shard.
|
|
1131
|
+
think=job.get("think"),
|
|
1132
|
+
think_effort=job.get("think_effort") or "",
|
|
1133
|
+
), timeout=self.job_timeout_s)
|
|
1134
|
+
took_ms = (time.time() - start) * 1000.0
|
|
1135
|
+
self.recent_ms.append(took_ms)
|
|
1136
|
+
if shard_index == 0:
|
|
1137
|
+
self._record_job(result["tokens"])
|
|
1138
|
+
shard_reply = {
|
|
1139
|
+
"type": "result",
|
|
1140
|
+
"id": job_id,
|
|
1141
|
+
"shard_index": shard_index,
|
|
1142
|
+
# Only the lead returns the assembled completion; others just timing.
|
|
1143
|
+
"content": result["content"] if shard_index == 0 else "",
|
|
1144
|
+
# Lead-only for the same reason `content` is: a pooled turn has one answer and one
|
|
1145
|
+
# line of thinking behind it, and a follower's slice is not either of them.
|
|
1146
|
+
"reasoning": result.get("reasoning", "") if shard_index == 0 else "",
|
|
1147
|
+
"tokens": result["tokens"] if shard_index == 0 else 0,
|
|
1148
|
+
"prompt_tokens": result["prompt_tokens"] if shard_index == 0 else 0,
|
|
1149
|
+
"backend": result["backend"],
|
|
1150
|
+
"compute_ms": round(took_ms, 1),
|
|
1151
|
+
"memory_gb": self.memory_gb,
|
|
1152
|
+
}
|
|
1153
|
+
# `content` is in `_SEALED_REPLY_FIELDS`; `shard_index`, `tokens` and the timings stay
|
|
1154
|
+
# readable, because the gateway needs them to assemble the pool's answer and to meter
|
|
1155
|
+
# it, and none of them reveals what the user said (22-A2).
|
|
1156
|
+
if reply_session is not None:
|
|
1157
|
+
shard_reply = self._seal_reply(shard_reply, reply_session, job_id)
|
|
1158
|
+
await self._send(ws, shard_reply)
|
|
1159
|
+
except asyncio.TimeoutError:
|
|
1160
|
+
await self._send(ws, {
|
|
1161
|
+
"type": "error", "id": job_id, "shard_index": shard_index,
|
|
1162
|
+
"message": (f"shard job exceeded this node's {self.job_timeout_s:.0f}s "
|
|
1163
|
+
f"limit and was cancelled"),
|
|
1164
|
+
})
|
|
1165
|
+
except Exception as e:
|
|
1166
|
+
# The pooled path's copy of the same leak, and it had to be fixed in the same
|
|
1167
|
+
# commit: a defect closed on one path and left open on the other just moves the
|
|
1168
|
+
# leak to whichever path the caller can reach.
|
|
1169
|
+
print(f"[{self.name}] shard {shard_index} of job {job_id} failed: "
|
|
1170
|
+
f"{type(e).__name__}: {e}")
|
|
1171
|
+
await self._send(ws, {
|
|
1172
|
+
"type": "error", "id": job_id, "shard_index": shard_index,
|
|
1173
|
+
"message": f"shard job failed: {type(e).__name__}",
|
|
1174
|
+
})
|
|
1175
|
+
finally:
|
|
1176
|
+
self._cancel_jobs.discard(job_id)
|
|
1177
|
+
self._inflight.discard(job_id)
|
|
1178
|
+
# The session dies with the job it was opened for. Left behind, it would hold a key
|
|
1179
|
+
# for a request that is over and let the next job to reuse this id look like one the
|
|
1180
|
+
# gateway had asked to have sealed.
|
|
1181
|
+
self._reply_sessions.pop(job_id, None)
|
|
1182
|
+
self._release_slot()
|
|
1183
|
+
|
|
1184
|
+
async def _serve_once(self, url: str = "", rotate: bool = False) -> None:
|
|
1185
|
+
target = url or self.gateway_url
|
|
1186
|
+
async with websockets.connect(target, max_size=8 * 1024 * 1024) as ws:
|
|
1187
|
+
await self._send(ws, self._register_msg(rotate))
|
|
1188
|
+
self.connected = True
|
|
1189
|
+
print(f"[{self.name}] connected to gateway {url or self.gateway_url} "
|
|
1190
|
+
f"(region={self.region}, models={self.models or 'any'})")
|
|
1191
|
+
# Connected is NOT serving: registration can still be refused (duplicate id, revoked
|
|
1192
|
+
# credential). Only the register reply below may claim the link is up.
|
|
1193
|
+
_publish_link_state("connected")
|
|
1194
|
+
hb = asyncio.create_task(self._heartbeat_loop(ws))
|
|
1195
|
+
# Open the replacement BEFORE this socket's deadline, so there is never a moment
|
|
1196
|
+
# when this node has no socket at all. The gateway then drains and closes this one.
|
|
1197
|
+
rot = asyncio.create_task(self._rotate_when_due(target))
|
|
1198
|
+
self._live_sockets[target] = self._live_sockets.get(target, 0) + 1
|
|
1199
|
+
try:
|
|
1200
|
+
async for raw in ws:
|
|
1201
|
+
# ONE BAD FRAME IS NOT A REASON TO DROP THE CONNECTION. `json.loads`
|
|
1202
|
+
# and `msg.get` used to sit bare inside this loop, so `'{"type":"job"'`
|
|
1203
|
+
# (JSONDecodeError), `'[]'`/`'"x"'`/`'null'` (AttributeError) or a
|
|
1204
|
+
# deeply nested array (RecursionError) tore the socket down and sent
|
|
1205
|
+
# the node round the reconnect backoff. Skip the frame instead; a
|
|
1206
|
+
# frame we cannot parse has no id to answer to anyway.
|
|
1207
|
+
try:
|
|
1208
|
+
msg = json.loads(raw)
|
|
1209
|
+
if not isinstance(msg, dict):
|
|
1210
|
+
continue
|
|
1211
|
+
if "id" in msg:
|
|
1212
|
+
msg["id"] = _frame_id(msg)
|
|
1213
|
+
mtype = msg.get("type")
|
|
1214
|
+
if mtype in ("job", "shard_job", "embed"):
|
|
1215
|
+
# A job we cannot name is a job we cannot answer: the result
|
|
1216
|
+
# frame would carry a null id the gateway can never match,
|
|
1217
|
+
# and the work would be pure burn on the owner's machine.
|
|
1218
|
+
# Refused BEFORE a slot is taken, and reported.
|
|
1219
|
+
if not isinstance(msg.get("id"), (str, int)) or msg["id"] == "":
|
|
1220
|
+
await self._send(ws, {
|
|
1221
|
+
"type": "error", "id": msg.get("id"),
|
|
1222
|
+
"message": "job frame has no usable id",
|
|
1223
|
+
})
|
|
1224
|
+
continue
|
|
1225
|
+
# CONCURRENCY CAP. Taken here, synchronously, before the task
|
|
1226
|
+
# exists: a node advertises max_concurrency and must honour it,
|
|
1227
|
+
# or one caller can start unlimited generations on the owner's
|
|
1228
|
+
# machine. Over-cap is refused immediately so the gateway
|
|
1229
|
+
# re-plans onto a node with room, rather than queueing.
|
|
1230
|
+
if not self._acquire_slot():
|
|
1231
|
+
self.rejected_jobs += 1
|
|
1232
|
+
await self._send(ws, {
|
|
1233
|
+
"type": "error", "id": msg.get("id"),
|
|
1234
|
+
"message": (f"node at capacity "
|
|
1235
|
+
f"({self.max_concurrency} concurrent jobs)"),
|
|
1236
|
+
})
|
|
1237
|
+
continue
|
|
1238
|
+
try:
|
|
1239
|
+
self._inflight.add(msg["id"])
|
|
1240
|
+
handler = (self._handle_job if mtype == "job"
|
|
1241
|
+
else self._handle_embed if mtype == "embed"
|
|
1242
|
+
else self._handle_shard_job)
|
|
1243
|
+
asyncio.create_task(handler(ws, msg))
|
|
1244
|
+
except BaseException:
|
|
1245
|
+
# Nothing between the acquire and the task may be able
|
|
1246
|
+
# to lose the slot — that is REAL 1's whole shape.
|
|
1247
|
+
self._inflight.discard(msg["id"])
|
|
1248
|
+
self._release_slot()
|
|
1249
|
+
raise
|
|
1250
|
+
elif mtype == "job_offer":
|
|
1251
|
+
# CAPTURE THE FLAG. The gateway posted a job; decide for OURSELVES
|
|
1252
|
+
# whether to race for it. Answering from our own live state is the
|
|
1253
|
+
# point of the design: the gateway's view of our load is always one
|
|
1254
|
+
# heartbeat stale, ours never is.
|
|
1255
|
+
#
|
|
1256
|
+
# Failures are LOGGED, never swallowed. A node that stops racing is
|
|
1257
|
+
# indistinguishable from a node that is busy, so a silent exception
|
|
1258
|
+
# here removes the machine from the network invisibly — which is
|
|
1259
|
+
# exactly what happened on 2026-08-21.
|
|
1260
|
+
try:
|
|
1261
|
+
await self._maybe_claim(ws, msg)
|
|
1262
|
+
except Exception as e:
|
|
1263
|
+
print(f"[node] could not race for {msg.get('id')}: "
|
|
1264
|
+
f"{type(e).__name__}: {e}")
|
|
1265
|
+
|
|
1266
|
+
elif mtype == "cancel":
|
|
1267
|
+
# §5A: the gateway's client bailed/steered — stop the stream job.
|
|
1268
|
+
# Only for a job actually running here: an id we have never
|
|
1269
|
+
# seen is either noise or an attempt to pre-cancel a FUTURE
|
|
1270
|
+
# job, and remembering every one of them was an unbounded set.
|
|
1271
|
+
jid = msg.get("id")
|
|
1272
|
+
if jid in self._inflight:
|
|
1273
|
+
self._cancel_jobs.add(jid)
|
|
1274
|
+
elif mtype == "registered":
|
|
1275
|
+
status = msg.get("status", "active")
|
|
1276
|
+
# Placement trust as the OPERATOR labelled us. Only ever lowers
|
|
1277
|
+
# or raises what this node will do locally (tool execution); it
|
|
1278
|
+
# is not something the node can claim for itself.
|
|
1279
|
+
self.trust = str(msg.get("trust") or "untrusted")
|
|
1280
|
+
note = (" — awaiting operator approval before it will "
|
|
1281
|
+
"receive traffic") if status == "pending" else ""
|
|
1282
|
+
print(f"[{self.name}] registered as id={msg.get('id')} "
|
|
1283
|
+
f"[{status}, trust={self.trust}]{note}")
|
|
1284
|
+
# THE join signal. A node `pending` operator approval is admitted but
|
|
1285
|
+
# gets no traffic, so it is not "serving" and must not read as such.
|
|
1286
|
+
_publish_link_state(
|
|
1287
|
+
"serving" if status == "active" else f"registered-{status}")
|
|
1288
|
+
# FILE 08-B2 — ANSWER THE ATTESTATION CHALLENGE, if this machine has
|
|
1289
|
+
# evidence to answer it with. An ordinary node has none and says nothing,
|
|
1290
|
+
# which is the correct behaviour: it simply never joins the TEE lane.
|
|
1291
|
+
await self._answer_tee_challenge(ws, str(msg.get("tee_challenge") or ""))
|
|
1292
|
+
elif mtype == "attested":
|
|
1293
|
+
# The gateway's verdict. Printed rather than swallowed because an operator
|
|
1294
|
+
# who has just brought up a confidential host has no other way to learn
|
|
1295
|
+
# whether their GPU is out of CC mode or the anchor is unpinned.
|
|
1296
|
+
if msg.get("lane"):
|
|
1297
|
+
print(f"[{self.name}] attested — serving the confidential (TEE) lane")
|
|
1298
|
+
elif msg.get("error"):
|
|
1299
|
+
print(f"[{self.name}] attestation was NOT accepted: {msg['error']}")
|
|
1300
|
+
except Exception as e:
|
|
1301
|
+
print(f"[{self.name}] ignoring unusable frame "
|
|
1302
|
+
f"({type(e).__name__}: {e})")
|
|
1303
|
+
continue
|
|
1304
|
+
finally:
|
|
1305
|
+
hb.cancel()
|
|
1306
|
+
rot.cancel()
|
|
1307
|
+
self._live_sockets[target] = max(0, self._live_sockets.get(target, 1) - 1)
|
|
1308
|
+
self.connected = False
|
|
1309
|
+
|
|
1310
|
+
async def _rotate_when_due(self, url: str) -> None:
|
|
1311
|
+
"""Open a replacement socket before this one's deadline (F-20.3).
|
|
1312
|
+
|
|
1313
|
+
The replacement runs the ordinary connect loop with `rotate` set on its first register, so
|
|
1314
|
+
it is subject to every admission check a fresh socket is. Once the gateway installs it,
|
|
1315
|
+
this node's old socket is drained and closed with 4005, and `_run_one` recognises that as
|
|
1316
|
+
"your replacement is live" rather than as a failure to retry.
|
|
1317
|
+
"""
|
|
1318
|
+
try:
|
|
1319
|
+
await asyncio.sleep(SOCKET_MAX_AGE_S)
|
|
1320
|
+
except asyncio.CancelledError:
|
|
1321
|
+
return
|
|
1322
|
+
if self._stop:
|
|
1323
|
+
return
|
|
1324
|
+
print(f"[{self.name}] socket reached {SOCKET_MAX_AGE_S:.0f}s — opening its replacement "
|
|
1325
|
+
f"before this one is closed")
|
|
1326
|
+
task = asyncio.ensure_future(self._run_one(url, first_rotate=True))
|
|
1327
|
+
self._rotations.add(task)
|
|
1328
|
+
task.add_done_callback(self._rotations.discard)
|
|
1329
|
+
|
|
1330
|
+
def _start_gui(self) -> None:
|
|
1331
|
+
if not self.gui_port:
|
|
1332
|
+
return
|
|
1333
|
+
try:
|
|
1334
|
+
from . import gui
|
|
1335
|
+
url = gui.start_gui(self.stats, self.stop, self.gui_port)
|
|
1336
|
+
print(f"[{self.name}] dashboard: open {url} in your browser")
|
|
1337
|
+
except Exception as e:
|
|
1338
|
+
print(f"[{self.name}] dashboard could not start: {type(e).__name__}: {e}")
|
|
1339
|
+
|
|
1340
|
+
def _refresh_credential(self) -> bool:
|
|
1341
|
+
"""Re-read the credential from its source before dialling. True if it changed
|
|
1342
|
+
(i.e. the owner dropped in a fresh one), so we can say so and retry at once."""
|
|
1343
|
+
current = _load_credential(self._cred_args)
|
|
1344
|
+
if current and current != self.credential:
|
|
1345
|
+
self.credential = current
|
|
1346
|
+
return True
|
|
1347
|
+
return False
|
|
1348
|
+
|
|
1349
|
+
def gateway_urls(self) -> list:
|
|
1350
|
+
"""Which gateways this node keeps a socket open to (file 02-A3).
|
|
1351
|
+
|
|
1352
|
+
Two by default. One socket means one gateway restart takes the node off the network until
|
|
1353
|
+
it reconnects; with two the restart is a routine event rather than an incident.
|
|
1354
|
+
|
|
1355
|
+
WHERE THE LIST COMES FROM (02-A3 decision table)
|
|
1356
|
+
"Directory returns the region's gateway list sorted by RTT probe; node takes first two;
|
|
1357
|
+
re-probe every 10 min." So the directory is asked first and the node measures the RTT
|
|
1358
|
+
itself. `STEVAI_GATEWAY_URLS` remains an explicit override, and the single
|
|
1359
|
+
`--gateway-url` is the last resort — a node must still come up if the directory is down.
|
|
1360
|
+
|
|
1361
|
+
REGION-SCOPED, ALWAYS. A node that dials another region's gateway advertises itself as
|
|
1362
|
+
capacity in that region, and the directory keeps ONE gateway per node (last heartbeat
|
|
1363
|
+
wins) — so the node flaps between regions and `/discover` intermittently finds nothing.
|
|
1364
|
+
Measured live 2026-08-28 with two nodes wrongly armed across eu-west and us-east: 7 of
|
|
1365
|
+
12 turns failed with "no network gateway with an active provider".
|
|
1366
|
+
"""
|
|
1367
|
+
want = max(1, int(envvar("NODE_GATEWAYS", "2") or 2))
|
|
1368
|
+
|
|
1369
|
+
regional = self._directory_gateways() # region-scoped by construction
|
|
1370
|
+
|
|
1371
|
+
override = envvar("GATEWAY_URLS", "").strip()
|
|
1372
|
+
urls = [u.strip() for u in override.split(",") if u.strip()]
|
|
1373
|
+
if urls and self._foreign_gateways():
|
|
1374
|
+
# THE OVERRIDE IS NOT ALLOWED OUT OF ITS REGION. The docstring above has always said
|
|
1375
|
+
# "REGION-SCOPED, ALWAYS" and this path did not enforce it: `STEVAI_GATEWAY_URLS` in
|
|
1376
|
+
# the RunPod specs lists ALL THREE regions, so an eu-west node sorted them by RTT and
|
|
1377
|
+
# dialled us-east.
|
|
1378
|
+
#
|
|
1379
|
+
# That is not a latency curiosity. The directory keeps ONE gateway per node, so the
|
|
1380
|
+
# node registered under `gw-us-east-a` while declaring `region=eu-west`, and
|
|
1381
|
+
# `/discover` for eu-west — which requires a node whose gateway belongs to an EU
|
|
1382
|
+
# gateway — correctly found nothing. Measured 2026-09-28: TWENTY consecutive turns,
|
|
1383
|
+
# zero landed, every one refused at `/discover`. It is also plainly wrong for data
|
|
1384
|
+
# residency (plan 29): EU traffic served through a US gateway.
|
|
1385
|
+
# DROP ONLY WHAT IS KNOWN TO BE SOMEONE ELSE'S REGION. "Not in my region's list" is
|
|
1386
|
+
# not the same as "in another region": an operator pinning a brand-new or private
|
|
1387
|
+
# gateway would be overruled by a control plane that has simply not heard of it yet,
|
|
1388
|
+
# and an explicit pin must not be silently discarded. So the test is positive
|
|
1389
|
+
# evidence of foreignness, taken from the directory's own region labels.
|
|
1390
|
+
foreign = self._foreign_gateways()
|
|
1391
|
+
kept = [u for u in urls if u not in foreign]
|
|
1392
|
+
dropped = [u for u in urls if u in foreign]
|
|
1393
|
+
if dropped:
|
|
1394
|
+
print(f"[{self.name}] ignoring {len(dropped)} configured gateway(s) outside "
|
|
1395
|
+
f"region {self.region!r}: {', '.join(dropped)}")
|
|
1396
|
+
urls = kept
|
|
1397
|
+
if not urls:
|
|
1398
|
+
print(f"[{self.name}] every configured gateway belongs to another region — "
|
|
1399
|
+
f"using region {self.region!r}'s list from the directory instead")
|
|
1400
|
+
urls = list(regional)
|
|
1401
|
+
elif not urls:
|
|
1402
|
+
urls = regional
|
|
1403
|
+
if not urls:
|
|
1404
|
+
# Last resort: the directory is unreachable AND nothing was configured for this
|
|
1405
|
+
# region. `--gateway-url` is region-specific by deployment, so it is the safe default.
|
|
1406
|
+
urls = [self.gateway_url]
|
|
1407
|
+
|
|
1408
|
+
# Distinct gateways first, in RTT order — the nearest is primary, the next is standby.
|
|
1409
|
+
seen, distinct = set(), []
|
|
1410
|
+
for u in urls:
|
|
1411
|
+
if u and u not in seen:
|
|
1412
|
+
seen.add(u)
|
|
1413
|
+
distinct.append(u)
|
|
1414
|
+
if len(distinct) > 1:
|
|
1415
|
+
# IN-REGION FIRST, THEN NEAREST. Ordering, not filtering: nothing is discarded, so an
|
|
1416
|
+
# operator's explicit pin is still used and an unknown gateway is still kept — the two
|
|
1417
|
+
# properties `test_dual_gateway_sockets` pins, and which an earlier attempt of mine
|
|
1418
|
+
# broke by preferring the regional list outright.
|
|
1419
|
+
#
|
|
1420
|
+
# Pure RTT order is wrong for the thing that actually matters. Measured 2026-09-29: a
|
|
1421
|
+
# node whose spec lists all three regions was placed far from Europe, sorted by RTT,
|
|
1422
|
+
# and took asia-southeast1 + us-east1 — while DECLARING `region=eu-west`. Its capacity
|
|
1423
|
+
# is eu-west, so the gateway correctly refused every turn ("No machine in 'us-east'
|
|
1424
|
+
# can run qwen3-8b right now. It is available in eu-west"), `/discover` had nothing to
|
|
1425
|
+
# hand out, and the node served nothing while looking perfectly healthy.
|
|
1426
|
+
#
|
|
1427
|
+
# `regional` is the directory's list for THIS node's region, so membership in it is
|
|
1428
|
+
# positive evidence of being in-region — the same evidence `_foreign_gateways` uses,
|
|
1429
|
+
# read the other way round. Latency still decides between two gateways that are both
|
|
1430
|
+
# in region, which is what the RTT probe was for.
|
|
1431
|
+
in_region = set(regional)
|
|
1432
|
+
distinct.sort(key=lambda u: (0 if u in in_region else 1, self._rtt_s(u)))
|
|
1433
|
+
|
|
1434
|
+
# PADDING REMOVED 2026-09-28, AFTER IT COST 18 TURNS IN 20.
|
|
1435
|
+
#
|
|
1436
|
+
# The padding repeated one url to reach `want` sockets, reasoning that two sockets to a
|
|
1437
|
+
# min-instances=2 service land on two different instances and give A3's redundancy. The
|
|
1438
|
+
# reasoning is sound and the gateway forbids the thing it relies on: `gateway.py` allows
|
|
1439
|
+
# ONE socket per node id PER INSTANCE, and a second closes the first with 4004 (there is a
|
|
1440
|
+
# reclaim path for a silent socket, and a hard refusal otherwise). Whether the two sockets
|
|
1441
|
+
# land on different instances is Cloud Run's choice, not ours — so padding is a coin flip
|
|
1442
|
+
# between "redundancy" and "the node evicts itself".
|
|
1443
|
+
#
|
|
1444
|
+
# It landed on the wrong side. With NODE_GATEWAYS=2 and one region url, the live gateway
|
|
1445
|
+
# log was a continuous alternation for a single node:
|
|
1446
|
+
# provider connected: stevai-warm-1 / provider gone: stevai-warm-1 / connected / gone …
|
|
1447
|
+
# and 20 consecutive real turns produced 2 answers: 17 failed at `/discover` with "no
|
|
1448
|
+
# gateway with an active provider", because the node's own two sockets kept knocking each
|
|
1449
|
+
# other off and the directory's view of it was stale most of the time.
|
|
1450
|
+
#
|
|
1451
|
+
# So: ONE SOCKET PER DISTINCT GATEWAY, never the same gateway twice. A region that
|
|
1452
|
+
# publishes one gateway gets one socket, and that is reported rather than papered over.
|
|
1453
|
+
# The instance-coverage problem the padding was reaching for is REAL and is a gateway-side
|
|
1454
|
+
# question (an instance holding no socket cannot serve), but it cannot be solved by making
|
|
1455
|
+
# a node fight itself.
|
|
1456
|
+
if len(distinct) < want:
|
|
1457
|
+
print(f"[{self.name}] region publishes {len(distinct)} distinct gateway(s); "
|
|
1458
|
+
f"holding {len(distinct)} socket(s), not {want} — a second socket to the SAME "
|
|
1459
|
+
f"gateway would be refused as a duplicate of this node id")
|
|
1460
|
+
return distinct[:want]
|
|
1461
|
+
|
|
1462
|
+
#: Re-probe cadence for the region's gateway list (02-A3: "re-probe every 10 min").
|
|
1463
|
+
REPROBE_S = float(envvar("GATEWAY_REPROBE_S", "600"))
|
|
1464
|
+
|
|
1465
|
+
def _foreign_gateways(self) -> set:
|
|
1466
|
+
"""Gateway urls the directory says belong to a region that is NOT this node's.
|
|
1467
|
+
|
|
1468
|
+
Used to police `STEVAI_GATEWAY_URLS`, which the RunPod pod specs populate with ALL THREE
|
|
1469
|
+
regions. An eu-west node sorted those by RTT and dialled us-east; the directory keeps one
|
|
1470
|
+
gateway per node, so it registered under `gw-us-east-a` while declaring `region=eu-west`,
|
|
1471
|
+
and `/discover` for eu-west found no provider — twenty consecutive turns, zero landed
|
|
1472
|
+
(2026-09-28). It is also simply wrong for data residency (plan 29).
|
|
1473
|
+
|
|
1474
|
+
Empty on any failure, so a directory blink can never strand a node: unknown is treated as
|
|
1475
|
+
allowed, and only POSITIVE evidence of another region removes a url.
|
|
1476
|
+
"""
|
|
1477
|
+
now = time.time()
|
|
1478
|
+
cached = getattr(self, "_foreign_cache", None)
|
|
1479
|
+
if cached and (now - cached[0]) < self.REPROBE_S:
|
|
1480
|
+
return set(cached[1])
|
|
1481
|
+
base = envvar("DIRECTORY_URL", "").rstrip("/")
|
|
1482
|
+
if not base:
|
|
1483
|
+
return set()
|
|
1484
|
+
try:
|
|
1485
|
+
import urllib.request
|
|
1486
|
+
with urllib.request.urlopen(f"{base}/gateways", timeout=8) as r:
|
|
1487
|
+
data = json.loads(r.read() or b"{}")
|
|
1488
|
+
except Exception as e:
|
|
1489
|
+
print(f"[{self.name}] could not fetch the full gateway list "
|
|
1490
|
+
f"({type(e).__name__}); not policing the configured list")
|
|
1491
|
+
return set()
|
|
1492
|
+
mine = (self.region or "").strip()
|
|
1493
|
+
out = {str(g.get("url") or "") for g in (data.get("gateways") or [])
|
|
1494
|
+
if str(g.get("region") or "") != mine and g.get("url")}
|
|
1495
|
+
self._foreign_cache = (now, set(out))
|
|
1496
|
+
return out
|
|
1497
|
+
|
|
1498
|
+
def _directory_gateways(self) -> list:
|
|
1499
|
+
"""The node's own region's gateways, newest view, as provider WebSocket urls.
|
|
1500
|
+
|
|
1501
|
+
Cached for `REPROBE_S`; every failure returns [] so the caller falls back rather than
|
|
1502
|
+
the node failing to start because the control plane blinked.
|
|
1503
|
+
"""
|
|
1504
|
+
now = time.time()
|
|
1505
|
+
cached = getattr(self, "_gw_cache", None)
|
|
1506
|
+
if cached and (now - cached[0]) < self.REPROBE_S:
|
|
1507
|
+
return list(cached[1])
|
|
1508
|
+
|
|
1509
|
+
base = envvar("DIRECTORY_URL", "").rstrip("/")
|
|
1510
|
+
if not base:
|
|
1511
|
+
return []
|
|
1512
|
+
try:
|
|
1513
|
+
import urllib.parse
|
|
1514
|
+
import urllib.request
|
|
1515
|
+
q = urllib.parse.urlencode({"region": self.region or ""})
|
|
1516
|
+
with urllib.request.urlopen(f"{base}/gateways?{q}", timeout=8) as r:
|
|
1517
|
+
data = json.loads(r.read() or b"{}")
|
|
1518
|
+
except Exception as e:
|
|
1519
|
+
print(f"[{self.name}] could not fetch the region gateway list ({type(e).__name__}); "
|
|
1520
|
+
f"falling back to the configured gateway")
|
|
1521
|
+
return []
|
|
1522
|
+
|
|
1523
|
+
urls = []
|
|
1524
|
+
for g in data.get("gateways") or []:
|
|
1525
|
+
# Trust but verify: the directory filters by region, and so do we. A node must never
|
|
1526
|
+
# hold a socket to another region's gateway, whatever the control plane returns.
|
|
1527
|
+
if self.region and g.get("region") and g["region"] != self.region:
|
|
1528
|
+
continue
|
|
1529
|
+
ws = _provider_ws_url(str(g.get("url") or ""))
|
|
1530
|
+
if ws:
|
|
1531
|
+
urls.append(ws)
|
|
1532
|
+
self._gw_cache = (now, list(urls))
|
|
1533
|
+
return urls
|
|
1534
|
+
|
|
1535
|
+
def _rtt_s(self, ws_url: str) -> float:
|
|
1536
|
+
"""Round-trip time to a gateway, for primary/standby ordering. inf when unreachable, so
|
|
1537
|
+
a dead gateway sorts last instead of becoming the primary."""
|
|
1538
|
+
import urllib.request
|
|
1539
|
+
health = ws_url.replace("wss://", "https://").replace("ws://", "http://")
|
|
1540
|
+
health = health.split("/provider/ws")[0] + "/health"
|
|
1541
|
+
t0 = time.time()
|
|
1542
|
+
try:
|
|
1543
|
+
urllib.request.urlopen(health, timeout=4).read(1)
|
|
1544
|
+
return time.time() - t0
|
|
1545
|
+
except Exception:
|
|
1546
|
+
return float("inf")
|
|
1547
|
+
|
|
1548
|
+
def _check_own_build(self) -> None:
|
|
1549
|
+
"""FILE 22 — is the code this node is about to run the code we published?
|
|
1550
|
+
|
|
1551
|
+
⚠️ REWRITTEN AFTER A REVIEW PROVED THE FIRST VERSION HANDED AN UNAUTHENTICATED ATTACKER A
|
|
1552
|
+
FLEET KILL SWITCH, and turned a normal release into a crash-loop on billing pods.
|
|
1553
|
+
|
|
1554
|
+
The first version raised `REFUSING TO SERVE` on any signature that did not hold and on any digest
|
|
1555
|
+
that differed. Two consequences, both worse than the gap it closed:
|
|
1556
|
+
|
|
1557
|
+
1. **Anyone who can answer `/node-package`** — DNS, a proxy, a compromised sibling — could reply
|
|
1558
|
+
`{"signed": true, "signature": "garbage"}` and stop every node in the fleet. NO KEY REQUIRED.
|
|
1559
|
+
`deploy/runpod/entrypoint.sh` restarts the agent forever, so that is an infinite crash-loop on
|
|
1560
|
+
a rented GPU serving nothing, which is the exact failure the comment above that loop was
|
|
1561
|
+
written about after it cost real money.
|
|
1562
|
+
2. **A digest mismatch is the repo's own normal release order.** `config.py`'s
|
|
1563
|
+
`extra_agent_digests` exists precisely because "a directory only ever knows the digest of the
|
|
1564
|
+
code IT was built from, so any node-agent change locks out every still-running older node the
|
|
1565
|
+
moment the directory deploys first". The old node is SUPPOSED to keep serving through the
|
|
1566
|
+
rollout, and admission — which is authenticated — decides. My check refused instead.
|
|
1567
|
+
|
|
1568
|
+
So the asymmetry is now about WHAT WE CAN CONCLUDE, not about how bad the news sounds:
|
|
1569
|
+
|
|
1570
|
+
* **A signature that does not verify tells us nothing at all.** The answer is not from Frutebox, so
|
|
1571
|
+
it is not evidence about this build — it is noise from whoever replied. Disregard it loudly and
|
|
1572
|
+
carry on. Refusing would let an unauthenticated stranger decide whether we serve.
|
|
1573
|
+
* **A verified manifest naming OUR version with a DIFFERENT digest is authenticated evidence that
|
|
1574
|
+
this node is running code that was not published under that version.** That refuses.
|
|
1575
|
+
* **A verified manifest naming a different version is a rollout.** Report the skew and carry on;
|
|
1576
|
+
the gateway's admission check is the authenticated gate for that, and it already handles it.
|
|
1577
|
+
|
|
1578
|
+
The remaining gap, stated because it is not closed here: the digest covers four modules
|
|
1579
|
+
(`attest._AGENT_FILES`), not the tarball, and nothing verifies the package before `install.sh`
|
|
1580
|
+
runs. See `docs/as-built/05-known-gaps.md`.
|
|
1581
|
+
"""
|
|
1582
|
+
base = envvar("DIRECTORY_URL", "").rstrip("/")
|
|
1583
|
+
if not base:
|
|
1584
|
+
# Nothing to compare against. A contributor laptop following the packaged `run-node.sh` sets
|
|
1585
|
+
# only the gateway URL and reaches here — which is why this returns rather than refuses, and
|
|
1586
|
+
# why the gateway's admission check remains the gate that actually matters.
|
|
1587
|
+
return
|
|
1588
|
+
strict = envvar("REQUIRE_SIGNED_PACKAGE", "").strip() == "1"
|
|
1589
|
+
try:
|
|
1590
|
+
import urllib.request
|
|
1591
|
+
with urllib.request.urlopen(f"{base}/node-package", timeout=8) as r:
|
|
1592
|
+
manifest = json.loads(r.read() or b"{}")
|
|
1593
|
+
except Exception as e:
|
|
1594
|
+
print(f"[{self.name}] could not fetch the package manifest ({type(e).__name__}); "
|
|
1595
|
+
f"the gateway still checks this build's digest at admission")
|
|
1596
|
+
if strict:
|
|
1597
|
+
raise
|
|
1598
|
+
return
|
|
1599
|
+
|
|
1600
|
+
mine = attest.agent_digest()
|
|
1601
|
+
if not manifest.get("signed"):
|
|
1602
|
+
msg = (f"[{self.name}] the control plane serves an UNSIGNED package manifest — this build's "
|
|
1603
|
+
f"digest cannot be attributed to Frutebox, only to whoever answered")
|
|
1604
|
+
if strict:
|
|
1605
|
+
raise RuntimeError(msg + " (FRUTEBOX_REQUIRE_SIGNED_PACKAGE=1)")
|
|
1606
|
+
print(msg)
|
|
1607
|
+
return
|
|
1608
|
+
try:
|
|
1609
|
+
signed = packaging.verify_package_manifest(manifest)
|
|
1610
|
+
except packaging.NoVerifyKeyHere as e:
|
|
1611
|
+
# A DISTINCT TYPE, not `if "not set" in str(e)`. That substring test was one reworded message
|
|
1612
|
+
# away from turning a refusal into a warning; this cannot drift.
|
|
1613
|
+
msg = (f"[{self.name}] cannot verify the package manifest: {e}. This node has no verify key, "
|
|
1614
|
+
f"so the manifest is not evidence either way. The gateway's admission check is "
|
|
1615
|
+
f"unaffected.")
|
|
1616
|
+
if strict:
|
|
1617
|
+
raise RuntimeError(msg + " (FRUTEBOX_REQUIRE_SIGNED_PACKAGE=1)") from e
|
|
1618
|
+
print(msg)
|
|
1619
|
+
return
|
|
1620
|
+
except packaging.PackageRejected as e:
|
|
1621
|
+
# NOT A REFUSAL. An answer we cannot authenticate is not evidence about this build, and
|
|
1622
|
+
# treating it as evidence is what made an unauthenticated reply a fleet-wide stop button.
|
|
1623
|
+
msg = (f"[{self.name}] DISREGARDING the package manifest: {e}. It is not attributable to "
|
|
1624
|
+
f"Frutebox, so it says nothing about this build. The gateway's admission check is the "
|
|
1625
|
+
f"authenticated gate and is unaffected.")
|
|
1626
|
+
if strict:
|
|
1627
|
+
raise RuntimeError(msg + " (FRUTEBOX_REQUIRE_SIGNED_PACKAGE=1)") from e
|
|
1628
|
+
print(msg)
|
|
1629
|
+
return
|
|
1630
|
+
|
|
1631
|
+
if signed["agent_digest"] == mine:
|
|
1632
|
+
print(f"[{self.name}] build verified against the signed manifest "
|
|
1633
|
+
f"(version {signed['version']}, {mine[:12]}…)")
|
|
1634
|
+
return
|
|
1635
|
+
if signed["version"] and signed["version"] != packaging.NODE_VERSION:
|
|
1636
|
+
# A rollout, not a wrong build. Both versions are meant to be admissible for its length.
|
|
1637
|
+
print(f"[{self.name}] the control plane publishes version {signed['version']} and this node "
|
|
1638
|
+
f"is {packaging.NODE_VERSION} — a rollout is in progress. Carrying on; admission "
|
|
1639
|
+
f"decides whether this digest is still accepted (see EXTRA_AGENT_DIGESTS).")
|
|
1640
|
+
return
|
|
1641
|
+
raise RuntimeError(
|
|
1642
|
+
f"[{self.name}] REFUSING TO SERVE: the signed manifest names version "
|
|
1643
|
+
f"{signed['version'] or '?'} — this node's own version — with agent digest "
|
|
1644
|
+
f"{signed['agent_digest'][:12]}…, and this node computes {mine[:12]}…. That is signed "
|
|
1645
|
+
f"evidence that this build was not published under this version. Re-fetch the package from "
|
|
1646
|
+
f"{base}/node-package rather than continuing.")
|
|
1647
|
+
|
|
1648
|
+
async def run(self) -> None:
|
|
1649
|
+
self._start_gui()
|
|
1650
|
+
self._check_own_build()
|
|
1651
|
+
urls = self.gateway_urls()
|
|
1652
|
+
if len(urls) == 1:
|
|
1653
|
+
await self._run_one(urls[0])
|
|
1654
|
+
return
|
|
1655
|
+
print(f"[{self.name}] holding {len(urls)} gateway sockets: {', '.join(urls)}")
|
|
1656
|
+
# Concurrency is still capped by `max_concurrency` on the node as a whole, so two
|
|
1657
|
+
# sockets cannot make the machine take twice the work — they only mean the job can
|
|
1658
|
+
# arrive by either route.
|
|
1659
|
+
await asyncio.gather(*(self._run_one(u) for u in urls))
|
|
1660
|
+
|
|
1661
|
+
async def _run_one(self, url: str, first_rotate: bool = False) -> None:
|
|
1662
|
+
backoff = 1.0
|
|
1663
|
+
rotate = first_rotate
|
|
1664
|
+
while not self._stop:
|
|
1665
|
+
try:
|
|
1666
|
+
# Pick up a replacement credential written since the last attempt —
|
|
1667
|
+
# this is how an expiry is actually resolved, rather than by retrying.
|
|
1668
|
+
self._refresh_credential()
|
|
1669
|
+
await self._serve_once(url, rotate=rotate)
|
|
1670
|
+
rotate = False # only the FIRST register of a replacement claims rotation
|
|
1671
|
+
backoff = 1.0
|
|
1672
|
+
except Exception as e:
|
|
1673
|
+
code = _close_code(e)
|
|
1674
|
+
# A REFUSED ROTATION IS NOT RE-ASSERTED. `rotate` is cleared only after a
|
|
1675
|
+
# SUCCESSFUL register, so a replacement socket whose rotation was refused used to
|
|
1676
|
+
# re-claim it on every retry — and the gateway refuses a rotation it cannot prove
|
|
1677
|
+
# every single time, so the two spun against each other forever.
|
|
1678
|
+
#
|
|
1679
|
+
# MEASURED 2026-10-14 on the media node, which has no credential: the gateway
|
|
1680
|
+
# logged "REFUSED rotation for 'frutebox-runpod-2': no verified credential" every
|
|
1681
|
+
# ~30s, the node logged 17 consecutive 4003s, and it held a socket almost never.
|
|
1682
|
+
# Media requests then failed as "no node is serving 'image'" — a node that cannot
|
|
1683
|
+
# keep a socket is indistinguishable from absent capacity. Note the bug is only
|
|
1684
|
+
# reachable WITHOUT a credential: the branch below handles a refusal for nodes
|
|
1685
|
+
# that have one, and it was the `and self.credential` that let this fall through
|
|
1686
|
+
# to the generic retry with the stale claim intact.
|
|
1687
|
+
#
|
|
1688
|
+
# Retrying as an ORDINARY socket is strictly better: it succeeds if the old socket
|
|
1689
|
+
# has gone, and is refused as a duplicate if it has not — in which case the live
|
|
1690
|
+
# socket is still serving and `_rotate_when_due` will come round again.
|
|
1691
|
+
if code == _CLOSE_REJECTED and rotate:
|
|
1692
|
+
print(f"[{self.name}] rotation was refused; retrying as an ordinary socket")
|
|
1693
|
+
rotate = False
|
|
1694
|
+
if code in (_CLOSE_CRED_EXPIRED, _CLOSE_REJECTED) and self.credential:
|
|
1695
|
+
# The gateway told us THIS credential is no good. Re-presenting it
|
|
1696
|
+
# on a 1s loop changes nothing and, before the gateway learned to
|
|
1697
|
+
# tell expiry from tampering, got honest nodes slashed off the
|
|
1698
|
+
# network in seconds. Say what to do, then wait properly.
|
|
1699
|
+
what = ("has EXPIRED" if code == _CLOSE_CRED_EXPIRED
|
|
1700
|
+
else "was REFUSED by the gateway")
|
|
1701
|
+
print(f"[{self.name}] node credential {what}. Get a fresh one "
|
|
1702
|
+
f"(the desktop app does this automatically; for this node ask "
|
|
1703
|
+
f"the operator / your dashboard for a new credential) and write "
|
|
1704
|
+
f"it to --credential-file or $FRUTEBOX_NODE_CREDENTIAL — it is "
|
|
1705
|
+
f"picked up on the next attempt, no restart needed. "
|
|
1706
|
+
f"Retrying in {_CRED_RETRY_S:.0f}s.")
|
|
1707
|
+
if self._stop:
|
|
1708
|
+
break
|
|
1709
|
+
# Poll the credential source while we wait so a fresh file is used
|
|
1710
|
+
# the moment it appears instead of after the full backoff.
|
|
1711
|
+
waited = 0.0
|
|
1712
|
+
while waited < _CRED_RETRY_S and not self._stop:
|
|
1713
|
+
await asyncio.sleep(min(5.0, _CRED_RETRY_S - waited))
|
|
1714
|
+
waited += 5.0
|
|
1715
|
+
if self._refresh_credential():
|
|
1716
|
+
print(f"[{self.name}] picked up a new credential — reconnecting")
|
|
1717
|
+
break
|
|
1718
|
+
backoff = 1.0
|
|
1719
|
+
continue
|
|
1720
|
+
if code == _CLOSE_DUPLICATE:
|
|
1721
|
+
print(f"[{self.name}] the gateway already has a live connection for "
|
|
1722
|
+
f"id '{self.id}' — another process is sharing under this id. "
|
|
1723
|
+
f"Retrying in {backoff:.0f}s.")
|
|
1724
|
+
elif code == _CLOSE_ROTATED:
|
|
1725
|
+
# Expected, and the whole point: this socket was replaced by one this node
|
|
1726
|
+
# opened itself, which is already serving. Reconnecting here would dial a
|
|
1727
|
+
# SECOND socket for an id that now has a live one and be refused as a
|
|
1728
|
+
# duplicate — so the old socket simply stands down, quietly.
|
|
1729
|
+
print(f"[{self.name}] socket rotated out; its replacement is serving")
|
|
1730
|
+
return
|
|
1731
|
+
else:
|
|
1732
|
+
print(f"[{self.name}] disconnected ({type(e).__name__}: {e}); "
|
|
1733
|
+
f"retrying in {backoff:.0f}s")
|
|
1734
|
+
_publish_link_state("disconnected")
|
|
1735
|
+
# NO STAND-DOWN. This used to `return` when another socket for this url was
|
|
1736
|
+
# registered, to avoid two tasks racing for one node id. It STRANDED THE URL, in
|
|
1737
|
+
# production, on 2026-09-29: the rotation task hit a refusal and returned, the
|
|
1738
|
+
# original had already stood down because the rotation was live at that moment,
|
|
1739
|
+
# and nothing was left to redial eu-west. The gateway sat at providers=0 while
|
|
1740
|
+
# the node reported itself healthy and served the other region.
|
|
1741
|
+
#
|
|
1742
|
+
# Racing is the cheaper failure. The gateway already enforces one socket per node
|
|
1743
|
+
# id and refuses the loser with 4004, which this loop treats as an ordinary
|
|
1744
|
+
# disconnect and retries with backoff. That is a little noise in a log. A URL
|
|
1745
|
+
# nothing reconnects is a region with no capacity until someone notices.
|
|
1746
|
+
if self._stop:
|
|
1747
|
+
break
|
|
1748
|
+
await asyncio.sleep(backoff)
|
|
1749
|
+
backoff = min(backoff * 2, 30.0)
|
|
1750
|
+
print(f"[{self.name}] stopped sharing on {url}.")
|
|
1751
|
+
|
|
1752
|
+
|
|
1753
|
+
async def _answer_tee_challenge(self, ws, challenge: str) -> None:
|
|
1754
|
+
"""FILE 08-B2 (node side) — present this machine's dual attestation, if it has one.
|
|
1755
|
+
|
|
1756
|
+
SILENT WHEN THERE IS NOTHING TO PRESENT, and that is the normal case: an ordinary node has no
|
|
1757
|
+
evidence files, sends no frame, and never joins the confidential lane. The gateway's default is
|
|
1758
|
+
already "not attested", so saying nothing is the safe answer rather than a missed opportunity.
|
|
1759
|
+
|
|
1760
|
+
NOTHING HERE CAN TAKE THE NODE DOWN. Every failure — no files, unreadable files, a socket that
|
|
1761
|
+
rejects the frame — is caught and logged, because this node's ORDINARY traffic is unrelated to
|
|
1762
|
+
the confidential lane and must not depend on it. The one thing that would be worse than not
|
|
1763
|
+
joining the lane is a confidential-computing feature that breaks plain inference.
|
|
1764
|
+
|
|
1765
|
+
The challenge is echoed back inside the evidence, not beside it: the JWTs are produced by the
|
|
1766
|
+
image's attestation tooling with the nonce baked into the signed report, which is the only form
|
|
1767
|
+
that proves anything. A node cannot add a nonce to someone else's signed report, which is
|
|
1768
|
+
exactly why the gateway insists on finding it there.
|
|
1769
|
+
"""
|
|
1770
|
+
if not challenge:
|
|
1771
|
+
return
|
|
1772
|
+
cpu_path, gpu_path = Path(TEE_CPU_EVIDENCE), Path(TEE_GPU_EVIDENCE)
|
|
1773
|
+
if not (cpu_path.exists() and gpu_path.exists()):
|
|
1774
|
+
return # not a confidential host; nothing to say
|
|
1775
|
+
try:
|
|
1776
|
+
cpu = cpu_path.read_text(encoding="utf-8").strip()
|
|
1777
|
+
gpu = gpu_path.read_text(encoding="utf-8").strip()
|
|
1778
|
+
except OSError as e:
|
|
1779
|
+
print(f"[{self.name}] TEE evidence present but unreadable ({type(e).__name__}); "
|
|
1780
|
+
f"staying off the confidential lane")
|
|
1781
|
+
return
|
|
1782
|
+
if not (cpu and gpu):
|
|
1783
|
+
print(f"[{self.name}] TEE evidence is empty; staying off the confidential lane")
|
|
1784
|
+
return
|
|
1785
|
+
try:
|
|
1786
|
+
await ws.send(json.dumps({
|
|
1787
|
+
"type": "attestation", "cpu_tee": cpu, "gpu_tee": gpu,
|
|
1788
|
+
# Which verification path the image used. "maa" means Azure vouched for the CPU report
|
|
1789
|
+
# rather than us verifying it ourselves, and the gateway refuses it unless the
|
|
1790
|
+
# hyperscaler sub-lane is switched on — so it is declared, never assumed.
|
|
1791
|
+
"cpu_path": envvar("TEE_CPU_PATH", "self"),
|
|
1792
|
+
"provider": envvar("TEE_PROVIDER", ""),
|
|
1793
|
+
# NOT the binding. The gateway checks the nonce inside the signed reports; this is
|
|
1794
|
+
# echoed only so an operator reading a log can match the frame to the challenge.
|
|
1795
|
+
"challenge_echo": challenge[:16],
|
|
1796
|
+
}))
|
|
1797
|
+
print(f"[{self.name}] presented a TEE attestation for the gateway's challenge")
|
|
1798
|
+
except Exception as e:
|
|
1799
|
+
print(f"[{self.name}] could not send the TEE attestation ({type(e).__name__}); "
|
|
1800
|
+
f"ordinary traffic is unaffected")
|
|
1801
|
+
|
|
1802
|
+
#: Where a confidential host leaves its attestation evidence for this agent to read.
|
|
1803
|
+
#:
|
|
1804
|
+
#: FILES, not a subprocess. Producing the evidence means running `snpguest`/`tdx-attest` and calling
|
|
1805
|
+
#: NVIDIA's attestation SDK, which needs the right driver, the right privileges and a network call — all
|
|
1806
|
+
#: of it specific to the confidential image, none of it appropriate inside a hot node agent. So the image
|
|
1807
|
+
#: writes the two JWTs and this reads them. That also keeps the node agent testable and means a broken
|
|
1808
|
+
#: attestation tool cannot take the node's ORDINARY traffic down with it.
|
|
1809
|
+
TEE_CPU_EVIDENCE = envvar("TEE_CPU_EVIDENCE", "/run/frutebox/cpu_tee.jwt")
|
|
1810
|
+
TEE_GPU_EVIDENCE = envvar("TEE_GPU_EVIDENCE", "/run/frutebox/gpu_tee.jwt")
|
|
1811
|
+
|
|
1812
|
+
|
|
1813
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1814
|
+
p = argparse.ArgumentParser(description="frutebox provider node")
|
|
1815
|
+
p.add_argument("--name", default=None, help="Human-friendly node name")
|
|
1816
|
+
p.add_argument("--id", default=None, help="Stable node id (defaults to random)")
|
|
1817
|
+
p.add_argument("--region", default=CONFIG.default_region,
|
|
1818
|
+
choices=CONFIG.regions, help="Jurisdiction this hardware sits in")
|
|
1819
|
+
p.add_argument("--compute-type", default=None, choices=["gpu", "cpu"],
|
|
1820
|
+
help="Hardware class. Omit to auto-detect (GPU if nvidia-smi "
|
|
1821
|
+
"reports VRAM, else CPU). GPUs are primary; CPUs are a "
|
|
1822
|
+
"slower fallback used when no GPU is available.")
|
|
1823
|
+
p.add_argument("--memory-gb", type=float, default=0.0,
|
|
1824
|
+
help="Memory this node contributes (VRAM for GPU, RAM for CPU). "
|
|
1825
|
+
"Lets many small CPUs pool their RAM to hold a big model.")
|
|
1826
|
+
p.add_argument("--cpu-cores", type=int, default=0,
|
|
1827
|
+
help="CPU core count (used to estimate CPU throughput).")
|
|
1828
|
+
p.add_argument("--models", default="",
|
|
1829
|
+
help="Comma-separated models served (empty = serves anything)")
|
|
1830
|
+
p.add_argument("--cost-per-1k", type=float, default=CONFIG.default_cost_per_1k,
|
|
1831
|
+
help="Energy+maintenance cost charged per 1000 tokens")
|
|
1832
|
+
p.add_argument("--concurrency", type=int, default=0,
|
|
1833
|
+
help="Max simultaneous jobs this node will run — advertised AND "
|
|
1834
|
+
"enforced. 0 = the node-governance default "
|
|
1835
|
+
f"({CONFIG.node_max_concurrent_jobs}, $FRUTEBOX_NODE_MAX_CONCURRENT_JOBS).")
|
|
1836
|
+
p.add_argument("--job-timeout-s", type=float, default=0.0,
|
|
1837
|
+
help="Hard wall-clock limit for one job on this machine; the job is "
|
|
1838
|
+
"cancelled and reported as an error. 0 = the node-governance "
|
|
1839
|
+
f"default ({CONFIG.node_job_timeout_s:.0f}s, $FRUTEBOX_NODE_JOB_TIMEOUT_S).")
|
|
1840
|
+
p.add_argument("--gateway-url", default=None,
|
|
1841
|
+
help="Network gateway WebSocket URL to dial into")
|
|
1842
|
+
p.add_argument("--credential", default="",
|
|
1843
|
+
help="Signed node credential issued for this node id (admits "
|
|
1844
|
+
"this machine without a manual operator approval). It is "
|
|
1845
|
+
"~4.5KB and secret-ish — prefer --credential-file.")
|
|
1846
|
+
p.add_argument("--credential-file", default="",
|
|
1847
|
+
help="Path to a file containing the signed node credential "
|
|
1848
|
+
"(falls back to $FRUTEBOX_NODE_CREDENTIAL).")
|
|
1849
|
+
p.add_argument("--gui-port", type=int, default=0,
|
|
1850
|
+
help="If set, serve the modern local dashboard on this port "
|
|
1851
|
+
"(e.g. --gui-port 8790 → http://127.0.0.1:8790)")
|
|
1852
|
+
return p
|
|
1853
|
+
|
|
1854
|
+
|
|
1855
|
+
def main() -> None:
|
|
1856
|
+
args = build_parser().parse_args()
|
|
1857
|
+
if args.gateway_url is None:
|
|
1858
|
+
args.gateway_url = envvar("GATEWAY_WS", "ws://127.0.0.1:8090/provider/ws")
|
|
1859
|
+
node = ProviderNode(args)
|
|
1860
|
+
try:
|
|
1861
|
+
asyncio.run(node.run())
|
|
1862
|
+
except KeyboardInterrupt:
|
|
1863
|
+
print(f"\n[{node.name}] shutting down")
|
|
1864
|
+
|
|
1865
|
+
|
|
1866
|
+
if __name__ == "__main__":
|
|
1867
|
+
main()
|