nvdc 0.2.2__tar.gz → 0.2.3__tar.gz
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.
- {nvdc-0.2.2 → nvdc-0.2.3}/PKG-INFO +11 -3
- {nvdc-0.2.2 → nvdc-0.2.3}/README.md +8 -2
- {nvdc-0.2.2 → nvdc-0.2.3}/pyproject.toml +4 -1
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc/__init__.py +1 -1
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc/agent.py +18 -11
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc/catalog.py +38 -11
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc/cli.py +4 -1
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc/coordinator.py +32 -9
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc/house.py +1 -0
- nvdc-0.2.3/src/nvdc/inference.py +350 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc/models.py +67 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc/protocol.py +1 -0
- nvdc-0.2.3/src/nvdc/store.py +105 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc.egg-info/PKG-INFO +11 -3
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc.egg-info/SOURCES.txt +2 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc.egg-info/requires.txt +3 -0
- nvdc-0.2.3/tests/test_mlx_custody.py +146 -0
- nvdc-0.2.2/src/nvdc/inference.py +0 -148
- {nvdc-0.2.2 → nvdc-0.2.3}/setup.cfg +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc/accounts.py +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc/app.py +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc/attestation.py +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc/config.py +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc/gpu.py +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc/hardware.py +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc/keys.py +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc/payments.py +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc/runtime.py +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc/storage.py +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc/update.py +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc/wallet.py +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc/web/index.html +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc.egg-info/dependency_links.txt +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc.egg-info/entry_points.txt +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/src/nvdc.egg-info/top_level.txt +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/tests/test_accounts_auth.py +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/tests/test_coordinator_security.py +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/tests/test_house_node.py +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/tests/test_model_attestation.py +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/tests/test_persistence.py +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/tests/test_self_serve.py +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/tests/test_self_update.py +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/tests/test_split_pricing.py +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/tests/test_storage_money.py +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/tests/test_stripe_webhook.py +0 -0
- {nvdc-0.2.2 → nvdc-0.2.3}/tests/test_version_gate.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nvdc
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Bring your GPU onto the network: one command turns a GPU into a verifiable, OpenAI-compatible inference node.
|
|
5
5
|
Author: NVDC
|
|
6
6
|
License: Apache-2.0
|
|
@@ -17,6 +17,8 @@ Requires-Dist: stripe>=9.0
|
|
|
17
17
|
Provides-Extra: attestation
|
|
18
18
|
Requires-Dist: nv-attestation-sdk>=2.7.0; extra == "attestation"
|
|
19
19
|
Requires-Dist: nv-local-gpu-verifier>=2.7.0; extra == "attestation"
|
|
20
|
+
Provides-Extra: mlx
|
|
21
|
+
Requires-Dist: mlx-lm>=0.22; extra == "mlx"
|
|
20
22
|
Provides-Extra: dev
|
|
21
23
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
22
24
|
|
|
@@ -127,8 +129,14 @@ client.chat.completions.create(model="llama3.1:8b",
|
|
|
127
129
|
## Models
|
|
128
130
|
|
|
129
131
|
The catalog is pinned to the **Ollama** library (reliable, known sizes; Ollama
|
|
130
|
-
also handles CUDA / Apple Metal / CPU offload). Each
|
|
131
|
-
|
|
132
|
+
also handles CUDA / Apple Metal / CPU offload). Each catalog entry also maps to
|
|
133
|
+
a canonical **MLX** artifact (4-bit, `mlx-community` on Hugging Face): run
|
|
134
|
+
`nvdc serve --backend mlx` and the client downloads the weights into its own
|
|
135
|
+
store (`~/.nvdc/models`), hashes them itself (full custody), re-verifies them
|
|
136
|
+
before every go-live, and serves them via a managed `mlx_lm.server` —
|
|
137
|
+
`pip install "nvdc[mlx]"` (Apple silicon; on Linux add `mlx[cuda]`).
|
|
138
|
+
Each node reports its memory budget and the UI marks every model
|
|
139
|
+
**Fits / Tight / Won't fit** against it:
|
|
132
140
|
|
|
133
141
|
- unified-memory systems (DGX Spark / GB10, Apple Silicon) → budget = system RAM
|
|
134
142
|
- dedicated-VRAM GPUs → budget = VRAM
|
|
@@ -105,8 +105,14 @@ client.chat.completions.create(model="llama3.1:8b",
|
|
|
105
105
|
## Models
|
|
106
106
|
|
|
107
107
|
The catalog is pinned to the **Ollama** library (reliable, known sizes; Ollama
|
|
108
|
-
also handles CUDA / Apple Metal / CPU offload). Each
|
|
109
|
-
|
|
108
|
+
also handles CUDA / Apple Metal / CPU offload). Each catalog entry also maps to
|
|
109
|
+
a canonical **MLX** artifact (4-bit, `mlx-community` on Hugging Face): run
|
|
110
|
+
`nvdc serve --backend mlx` and the client downloads the weights into its own
|
|
111
|
+
store (`~/.nvdc/models`), hashes them itself (full custody), re-verifies them
|
|
112
|
+
before every go-live, and serves them via a managed `mlx_lm.server` —
|
|
113
|
+
`pip install "nvdc[mlx]"` (Apple silicon; on Linux add `mlx[cuda]`).
|
|
114
|
+
Each node reports its memory budget and the UI marks every model
|
|
115
|
+
**Fits / Tight / Won't fit** against it:
|
|
110
116
|
|
|
111
117
|
- unified-memory systems (DGX Spark / GB10, Apple Silicon) → budget = system RAM
|
|
112
118
|
- dedicated-VRAM GPUs → budget = VRAM
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "nvdc"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.3"
|
|
4
4
|
description = "Bring your GPU onto the network: one command turns a GPU into a verifiable, OpenAI-compatible inference node."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.9"
|
|
@@ -19,6 +19,9 @@ dependencies = [
|
|
|
19
19
|
|
|
20
20
|
[project.optional-dependencies]
|
|
21
21
|
attestation = ["nv-attestation-sdk>=2.7.0", "nv-local-gpu-verifier>=2.7.0"]
|
|
22
|
+
# Apple-silicon (and MLX-CUDA) inference with full client custody of weights.
|
|
23
|
+
# On Linux, additionally install the CUDA flavor: pip install "mlx[cuda]"
|
|
24
|
+
mlx = ["mlx-lm>=0.22"]
|
|
22
25
|
dev = ["pytest>=8.0"]
|
|
23
26
|
|
|
24
27
|
[project.scripts]
|
|
@@ -21,7 +21,6 @@ from .gpu import detect_gpu, detect_gpus, detect_interconnect
|
|
|
21
21
|
from .hardware import detect_hardware, machine_id as hw_machine_id
|
|
22
22
|
from .inference import Backend, make_backend
|
|
23
23
|
from .keys import Identity
|
|
24
|
-
from .models import local_ollama_commit
|
|
25
24
|
|
|
26
25
|
log = logging.getLogger("nvdc.agent")
|
|
27
26
|
|
|
@@ -215,15 +214,15 @@ class NodeAgent:
|
|
|
215
214
|
)
|
|
216
215
|
# Model identity commitment: the content digest of the artifact this
|
|
217
216
|
# node will serve. Signed into every work receipt (nvdc-work-v2) and
|
|
218
|
-
# checked by the coordinator against
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
217
|
+
# checked by the coordinator against public ground truth. For MLX the
|
|
218
|
+
# client itself hashed the weights it downloaded (full custody); for
|
|
219
|
+
# Ollama it is the content digest the local daemon reports.
|
|
220
|
+
self.model_commit = await self.backend.model_commitment(self.model)
|
|
221
|
+
if self.model_commit:
|
|
222
|
+
log.info("model commitment: %s -> %s", self.model, self.model_commit[:23] + "…")
|
|
223
|
+
elif getattr(self.backend, "name", "") in ("ollama", "mlx"):
|
|
224
|
+
log.warning("could not derive a model commitment for '%s'; "
|
|
225
|
+
"receipts will not carry a model identity", self.model)
|
|
227
226
|
profile = protocol.NodeProfile(
|
|
228
227
|
name=self.name,
|
|
229
228
|
models=[self.model],
|
|
@@ -243,6 +242,7 @@ class NodeAgent:
|
|
|
243
242
|
cluster=self.cluster,
|
|
244
243
|
private=self.private,
|
|
245
244
|
model_commit=self.model_commit,
|
|
245
|
+
backend=getattr(self.backend, "name", "ollama"),
|
|
246
246
|
agent_version=__version__,
|
|
247
247
|
)
|
|
248
248
|
await self._send(protocol.MSG_REGISTER, profile=protocol.node_profile_to_dict(profile))
|
|
@@ -392,4 +392,11 @@ async def serve(
|
|
|
392
392
|
private=private,
|
|
393
393
|
auto_update=auto_update,
|
|
394
394
|
)
|
|
395
|
-
|
|
395
|
+
try:
|
|
396
|
+
await agent.run_forever()
|
|
397
|
+
finally:
|
|
398
|
+
# Backends that manage a server subprocess (MLX) must not orphan it
|
|
399
|
+
# when the agent exits (Ctrl-C, drain, upgrade-required, errors).
|
|
400
|
+
close = getattr(backend, "close", None)
|
|
401
|
+
if callable(close):
|
|
402
|
+
close()
|
|
@@ -25,37 +25,63 @@ class Model:
|
|
|
25
25
|
size_gb: float # published download size of this tag
|
|
26
26
|
min_memory_gb: float # recommended resident memory for hot inference
|
|
27
27
|
description: str
|
|
28
|
+
# Hugging Face repo of the canonical MLX artifact for this model (4-bit,
|
|
29
|
+
# mlx-community). The coordinator derives MLX ground-truth digests from
|
|
30
|
+
# THIS mapping — never from a node-supplied repo. "" = no MLX artifact.
|
|
31
|
+
mlx_repo: str = ""
|
|
28
32
|
|
|
29
33
|
|
|
30
34
|
# Curated, popular, reliably-available tags from the Ollama library.
|
|
31
35
|
CATALOG: List[Model] = [
|
|
32
36
|
Model("gpt-oss:20b", "GPT-OSS 20B", "gpt-oss", "20B", 14.0, 16.0,
|
|
33
|
-
"OpenAI's open-weight model; strong general reasoning, runs on a single 16GB+ GPU."
|
|
37
|
+
"OpenAI's open-weight model; strong general reasoning, runs on a single 16GB+ GPU.",
|
|
38
|
+
mlx_repo="mlx-community/gpt-oss-20b-MXFP4-Q4"),
|
|
34
39
|
Model("gpt-oss:120b", "GPT-OSS 120B", "gpt-oss", "120B", 65.0, 80.0,
|
|
35
|
-
"OpenAI's large open-weight model; needs ~80GB (high-end / unified-memory systems)."
|
|
40
|
+
"OpenAI's large open-weight model; needs ~80GB (high-end / unified-memory systems).",
|
|
41
|
+
mlx_repo="mlx-community/gpt-oss-120b-MXFP4-Q4"),
|
|
36
42
|
Model("llama3.1:8b", "Llama 3.1 8B", "llama", "8B", 4.9, 8.0,
|
|
37
|
-
"Meta Llama 3.1 8B Instruct; excellent quality-to-size, great default."
|
|
43
|
+
"Meta Llama 3.1 8B Instruct; excellent quality-to-size, great default.",
|
|
44
|
+
mlx_repo="mlx-community/Meta-Llama-3.1-8B-Instruct-4bit"),
|
|
38
45
|
Model("llama3.1:70b", "Llama 3.1 70B", "llama", "70B", 43.0, 48.0,
|
|
39
|
-
"Meta Llama 3.1 70B Instruct; frontier-class quality, needs a large GPU."
|
|
46
|
+
"Meta Llama 3.1 70B Instruct; frontier-class quality, needs a large GPU.",
|
|
47
|
+
mlx_repo="mlx-community/Meta-Llama-3.1-70B-Instruct-4bit"),
|
|
48
|
+
Model("llama3.2:1b", "Llama 3.2 1B", "llama", "1B", 1.3, 2.0,
|
|
49
|
+
"Tiny Llama 3.2; runs almost anywhere, good for testing and edge.",
|
|
50
|
+
mlx_repo="mlx-community/Llama-3.2-1B-Instruct-4bit"),
|
|
40
51
|
Model("llama3.2:3b", "Llama 3.2 3B", "llama", "3B", 2.0, 4.0,
|
|
41
|
-
"Small, fast Llama 3.2; good for low-memory GPUs and edge."
|
|
52
|
+
"Small, fast Llama 3.2; good for low-memory GPUs and edge.",
|
|
53
|
+
mlx_repo="mlx-community/Llama-3.2-3B-Instruct-4bit"),
|
|
42
54
|
Model("qwen2.5:7b", "Qwen2.5 7B", "qwen", "7B", 4.7, 8.0,
|
|
43
|
-
"Alibaba Qwen2.5 7B; strong multilingual and coding."
|
|
55
|
+
"Alibaba Qwen2.5 7B; strong multilingual and coding.",
|
|
56
|
+
mlx_repo="mlx-community/Qwen2.5-7B-Instruct-4bit"),
|
|
44
57
|
Model("qwen2.5:32b", "Qwen2.5 32B", "qwen", "32B", 20.0, 24.0,
|
|
45
|
-
"Qwen2.5 32B; high quality, needs a 24GB+ GPU."
|
|
58
|
+
"Qwen2.5 32B; high quality, needs a 24GB+ GPU.",
|
|
59
|
+
mlx_repo="mlx-community/Qwen2.5-32B-Instruct-4bit"),
|
|
46
60
|
Model("deepseek-r1:7b", "DeepSeek-R1 7B", "deepseek", "7B", 4.7, 8.0,
|
|
47
|
-
"Distilled reasoning model with visible chain-of-thought."
|
|
61
|
+
"Distilled reasoning model with visible chain-of-thought.",
|
|
62
|
+
mlx_repo="mlx-community/DeepSeek-R1-Distill-Qwen-7B-4bit"),
|
|
48
63
|
Model("deepseek-r1:70b", "DeepSeek-R1 70B", "deepseek", "70B", 43.0, 48.0,
|
|
49
64
|
"Large distilled reasoning model."),
|
|
50
65
|
Model("mistral:7b", "Mistral 7B", "mistral", "7B", 4.1, 6.0,
|
|
51
|
-
"Mistral 7B Instruct; fast, lightweight, widely used."
|
|
66
|
+
"Mistral 7B Instruct; fast, lightweight, widely used.",
|
|
67
|
+
mlx_repo="mlx-community/Mistral-7B-Instruct-v0.3-4bit"),
|
|
52
68
|
Model("gemma2:9b", "Gemma 2 9B", "gemma", "9B", 5.4, 9.0,
|
|
53
|
-
"Google Gemma 2 9B; balanced quality and size."
|
|
69
|
+
"Google Gemma 2 9B; balanced quality and size.",
|
|
70
|
+
mlx_repo="mlx-community/gemma-2-9b-it-4bit"),
|
|
54
71
|
Model("phi4:14b", "Phi-4 14B", "phi", "14B", 9.1, 12.0,
|
|
55
|
-
"Microsoft Phi-4; strong reasoning for its size."
|
|
72
|
+
"Microsoft Phi-4; strong reasoning for its size.",
|
|
73
|
+
mlx_repo="mlx-community/phi-4-4bit"),
|
|
56
74
|
]
|
|
57
75
|
|
|
58
76
|
|
|
77
|
+
def mlx_repo_for(model_id: str) -> str:
|
|
78
|
+
"""The canonical MLX artifact repo for a catalog model id ("" if none)."""
|
|
79
|
+
for m in CATALOG:
|
|
80
|
+
if m.id == model_id:
|
|
81
|
+
return m.mlx_repo
|
|
82
|
+
return ""
|
|
83
|
+
|
|
84
|
+
|
|
59
85
|
# Default reference market prices in USD per 1,000,000 output tokens. Used as a
|
|
60
86
|
# fallback when no miner is currently online serving a given model. Loosely
|
|
61
87
|
# tracks relative model size/cost; the live network price overrides these.
|
|
@@ -64,6 +90,7 @@ DEFAULT_PRICES = {
|
|
|
64
90
|
"gpt-oss:120b": 0.60,
|
|
65
91
|
"llama3.1:8b": 0.10,
|
|
66
92
|
"llama3.1:70b": 0.40,
|
|
93
|
+
"llama3.2:1b": 0.03,
|
|
67
94
|
"llama3.2:3b": 0.05,
|
|
68
95
|
"qwen2.5:7b": 0.10,
|
|
69
96
|
"qwen2.5:32b": 0.30,
|
|
@@ -163,7 +163,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
163
163
|
s.add_argument("--coordinator", "-c", default=DEFAULT_COORDINATOR_WS,
|
|
164
164
|
help=f"coordinator host/url (default: {DEFAULT_COORDINATOR_WS})")
|
|
165
165
|
s.add_argument("--name", "-n", default=os.uname().nodename, help="node display name")
|
|
166
|
-
s.add_argument("--backend", "-b", default="ollama",
|
|
166
|
+
s.add_argument("--backend", "-b", default="ollama",
|
|
167
|
+
choices=["ollama", "mlx", "echo"],
|
|
168
|
+
help="inference engine; 'mlx' downloads the canonical MLX "
|
|
169
|
+
"artifact into the client's own store (full custody)")
|
|
167
170
|
s.add_argument("--model", "-m", default="llama3.1:8b",
|
|
168
171
|
help="the single model this node commits to serving (hot-loaded)")
|
|
169
172
|
s.add_argument("--ollama-url", default="http://localhost:11434")
|
|
@@ -187,10 +187,13 @@ class Node:
|
|
|
187
187
|
self.machine_id = profile.get("machine_id", "") or self.id
|
|
188
188
|
self.cluster = profile.get("cluster", "") or ""
|
|
189
189
|
# Model identity: the node's claimed artifact digest, and the verdict
|
|
190
|
-
# from checking that claim against
|
|
190
|
+
# from checking that claim against public ground truth at registration
|
|
191
191
|
# ("verified" | "mismatch" | "unknown").
|
|
192
192
|
self.model_commit = str(profile.get("model_commit", "") or "")
|
|
193
193
|
self.model_verdict = "unknown"
|
|
194
|
+
# Inference engine; ground truth differs per backend (ollama registry
|
|
195
|
+
# manifest vs Hugging Face LFS digests for the catalog's MLX repo).
|
|
196
|
+
self.backend = str(profile.get("backend", "") or "ollama")
|
|
194
197
|
self.draining = False
|
|
195
198
|
self.pending: Dict[str, PendingRequest] = {}
|
|
196
199
|
self._send_lock = asyncio.Lock()
|
|
@@ -265,6 +268,7 @@ class Node:
|
|
|
265
268
|
"price_per_mtok": round(self.price_per_mtok, 6),
|
|
266
269
|
"price_per_mtok_input": round(self.price_per_mtok_input, 6),
|
|
267
270
|
"private": self.private,
|
|
271
|
+
"backend": self.backend,
|
|
268
272
|
"model_commit": self.model_commit,
|
|
269
273
|
"model_verdict": self.model_verdict,
|
|
270
274
|
"attested": self.attested,
|
|
@@ -511,24 +515,43 @@ def create_app(auth_token: str = "", require_attested: bool = False,
|
|
|
511
515
|
node.restore_stats(rec)
|
|
512
516
|
return
|
|
513
517
|
|
|
514
|
-
#
|
|
515
|
-
#
|
|
518
|
+
# Ground truth for model digests, cached so registration doesn't hammer
|
|
519
|
+
# the upstream sources (tags/repos are mutable, hence the TTL refresh).
|
|
516
520
|
_model_digest_cache: Dict[str, tuple] = {}
|
|
517
521
|
|
|
518
|
-
async def
|
|
519
|
-
hit = _model_digest_cache.get(
|
|
522
|
+
async def _cached(key: str, fetch) -> str:
|
|
523
|
+
hit = _model_digest_cache.get(key)
|
|
520
524
|
if hit and time.time() - hit[1] < 6 * 3600:
|
|
521
525
|
return hit[0]
|
|
522
|
-
digest = await
|
|
523
|
-
_model_digest_cache[
|
|
526
|
+
digest = await fetch()
|
|
527
|
+
_model_digest_cache[key] = (digest, time.time())
|
|
524
528
|
return digest
|
|
525
529
|
|
|
530
|
+
async def expected_model_digest(node: Node) -> str:
|
|
531
|
+
"""Authoritative digest for the node's model, per its backend."""
|
|
532
|
+
if node.backend == "mlx":
|
|
533
|
+
from .catalog import mlx_repo_for
|
|
534
|
+
repo = mlx_repo_for(node.model)
|
|
535
|
+
if not repo:
|
|
536
|
+
return "" # non-catalog model: no canonical MLX ground truth
|
|
537
|
+
|
|
538
|
+
async def fetch_mlx() -> str:
|
|
539
|
+
digests = await model_identity.fetch_hf_weights_digests(repo)
|
|
540
|
+
return model_identity.weights_commitment(digests) if digests else ""
|
|
541
|
+
|
|
542
|
+
return await _cached("mlx:" + repo, fetch_mlx)
|
|
543
|
+
if node.backend in ("api", "echo"):
|
|
544
|
+
return "" # no public artifact to anchor to
|
|
545
|
+
return await _cached(
|
|
546
|
+
"ollama:" + node.model,
|
|
547
|
+
lambda: model_identity.fetch_registry_digest(node.model))
|
|
548
|
+
|
|
526
549
|
async def _verify_node_model(node: Node) -> None:
|
|
527
|
-
"""Check the node's claimed artifact digest against
|
|
550
|
+
"""Check the node's claimed artifact digest against ground truth."""
|
|
528
551
|
if not node.model_commit:
|
|
529
552
|
return # no claim made -> verdict stays "unknown"
|
|
530
553
|
try:
|
|
531
|
-
expected = await expected_model_digest(node
|
|
554
|
+
expected = await expected_model_digest(node)
|
|
532
555
|
except Exception:
|
|
533
556
|
expected = ""
|
|
534
557
|
node.model_verdict = model_identity.model_verdict(node.model_commit, expected)
|
|
@@ -117,6 +117,7 @@ class HouseNode(Node):
|
|
|
117
117
|
"owner_account": identity.account_id,
|
|
118
118
|
"machine_id": "house-" + identity.account_id[-12:],
|
|
119
119
|
"cluster": "house",
|
|
120
|
+
"backend": "api",
|
|
120
121
|
"attestation": {
|
|
121
122
|
"supported": False, "verified": False, "mode": "none",
|
|
122
123
|
"reason": "house node: API-backed, no local GPU to attest",
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Local inference backends exposed behind a tiny async interface.
|
|
2
|
+
|
|
3
|
+
The node agent forwards an OpenAI ``/v1/chat/completions`` body to a backend and
|
|
4
|
+
relays whatever the backend produces straight back through the tunnel, so the
|
|
5
|
+
public API stays byte-for-byte OpenAI-compatible.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import atexit
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import platform
|
|
15
|
+
import socket
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, AsyncIterator, Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger("nvdc.inference")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Backend:
|
|
29
|
+
name = "base"
|
|
30
|
+
|
|
31
|
+
async def list_models(self) -> List[str]:
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
|
|
34
|
+
async def warm(self, model: str) -> None:
|
|
35
|
+
"""Load the model resident in memory for hot inference (best-effort)."""
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
async def model_commitment(self, model: str) -> str:
|
|
39
|
+
"""Content digest of the artifact this backend serves ("" if unknown).
|
|
40
|
+
|
|
41
|
+
Signed into every work receipt (nvdc-work-v2) and verified by the
|
|
42
|
+
coordinator against public ground truth.
|
|
43
|
+
"""
|
|
44
|
+
return ""
|
|
45
|
+
|
|
46
|
+
async def chat_stream(self, body: Dict[str, Any]) -> AsyncIterator[str]:
|
|
47
|
+
"""Yield raw SSE 'data: ...' lines (OpenAI streaming format)."""
|
|
48
|
+
raise NotImplementedError
|
|
49
|
+
yield # pragma: no cover
|
|
50
|
+
|
|
51
|
+
async def chat_once(self, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
52
|
+
"""Return a full OpenAI chat.completion object."""
|
|
53
|
+
raise NotImplementedError
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class OllamaBackend(Backend):
|
|
57
|
+
name = "ollama"
|
|
58
|
+
|
|
59
|
+
def __init__(self, base_url: str = "http://localhost:11434", default_model: str = ""):
|
|
60
|
+
self.base_url = base_url.rstrip("/")
|
|
61
|
+
self.default_model = default_model
|
|
62
|
+
|
|
63
|
+
async def list_models(self) -> List[str]:
|
|
64
|
+
async with httpx.AsyncClient(timeout=10) as c:
|
|
65
|
+
r = await c.get(f"{self.base_url}/api/tags")
|
|
66
|
+
r.raise_for_status()
|
|
67
|
+
return [m["name"] for m in r.json().get("models", [])]
|
|
68
|
+
|
|
69
|
+
async def warm(self, model: str) -> None:
|
|
70
|
+
async with httpx.AsyncClient(timeout=None) as c:
|
|
71
|
+
await c.post(f"{self.base_url}/api/generate", json={
|
|
72
|
+
"model": model, "prompt": "ok", "stream": False, "keep_alive": -1,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
async def model_commitment(self, model: str) -> str:
|
|
76
|
+
# Ollama owns its model store; the commitment is the content digest
|
|
77
|
+
# the (local) daemon reports for the tag it will serve.
|
|
78
|
+
from .models import local_ollama_commit
|
|
79
|
+
return await local_ollama_commit(model, self.base_url)
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _error(status: int, text: str, model: str) -> str:
|
|
83
|
+
detail = text
|
|
84
|
+
try:
|
|
85
|
+
j = json.loads(text)
|
|
86
|
+
detail = (j.get("error") or j.get("message") or text)
|
|
87
|
+
if isinstance(detail, dict):
|
|
88
|
+
detail = detail.get("message", text)
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
return f"ollama error {status} for model '{model}': {str(detail)[:400]}"
|
|
92
|
+
|
|
93
|
+
async def chat_once(self, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
94
|
+
body = dict(body)
|
|
95
|
+
body["stream"] = False
|
|
96
|
+
async with httpx.AsyncClient(timeout=None) as c:
|
|
97
|
+
r = await c.post(f"{self.base_url}/v1/chat/completions", json=body)
|
|
98
|
+
if r.status_code >= 400:
|
|
99
|
+
raise RuntimeError(self._error(r.status_code, r.text, body.get("model", "")))
|
|
100
|
+
return r.json()
|
|
101
|
+
|
|
102
|
+
async def chat_stream(self, body: Dict[str, Any]) -> AsyncIterator[str]:
|
|
103
|
+
body = dict(body)
|
|
104
|
+
body["stream"] = True
|
|
105
|
+
async with httpx.AsyncClient(timeout=None) as c:
|
|
106
|
+
async with c.stream("POST", f"{self.base_url}/v1/chat/completions", json=body) as r:
|
|
107
|
+
if r.status_code >= 400:
|
|
108
|
+
await r.aread()
|
|
109
|
+
raise RuntimeError(self._error(r.status_code, r.text, body.get("model", "")))
|
|
110
|
+
async for line in r.aiter_lines():
|
|
111
|
+
if line:
|
|
112
|
+
yield line
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def ensure_mlx_lm(auto_install: bool = True) -> bool:
|
|
116
|
+
"""True when mlx-lm is importable, installing it first when allowed.
|
|
117
|
+
|
|
118
|
+
Auto-install is attempted only on Apple silicon (where ``pip install
|
|
119
|
+
mlx-lm`` just works); on other platforms the operator picks the right
|
|
120
|
+
MLX flavor themselves (e.g. ``pip install mlx-lm 'mlx[cuda]'``).
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
import mlx_lm # noqa: F401
|
|
124
|
+
return True
|
|
125
|
+
except ImportError:
|
|
126
|
+
pass
|
|
127
|
+
if not (auto_install and sys.platform == "darwin" and platform.machine() == "arm64"):
|
|
128
|
+
return False
|
|
129
|
+
log.info("installing mlx-lm into this client's environment…")
|
|
130
|
+
proc = subprocess.run([sys.executable, "-m", "pip", "install", "mlx-lm"],
|
|
131
|
+
capture_output=True, text=True, timeout=900)
|
|
132
|
+
if proc.returncode != 0:
|
|
133
|
+
log.error("mlx-lm install failed: %s", (proc.stderr or "")[-400:])
|
|
134
|
+
return False
|
|
135
|
+
try:
|
|
136
|
+
import mlx_lm # noqa: F401
|
|
137
|
+
return True
|
|
138
|
+
except ImportError:
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _free_port() -> int:
|
|
143
|
+
with socket.socket() as s:
|
|
144
|
+
s.bind(("127.0.0.1", 0))
|
|
145
|
+
return s.getsockname()[1]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class MlxBackend(Backend):
|
|
149
|
+
"""Apple-silicon (and MLX-CUDA) backend with full client custody.
|
|
150
|
+
|
|
151
|
+
The client downloads the canonical MLX artifact from Hugging Face into its
|
|
152
|
+
own store, hashes the weights itself, re-verifies them before serving, and
|
|
153
|
+
runs ``mlx_lm.server`` (OpenAI-compatible) as a managed subprocess. The
|
|
154
|
+
model commitment signed into receipts describes bytes the client measured.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
name = "mlx"
|
|
158
|
+
|
|
159
|
+
def __init__(self, model: str, mlx_repo: str = "", store=None,
|
|
160
|
+
auto_install: bool = True):
|
|
161
|
+
from .catalog import mlx_repo_for
|
|
162
|
+
from .store import ModelStore
|
|
163
|
+
self.model = model
|
|
164
|
+
self.repo = mlx_repo or mlx_repo_for(model)
|
|
165
|
+
if not self.repo:
|
|
166
|
+
raise ValueError(
|
|
167
|
+
f"no canonical MLX artifact for '{model}' — pick a catalog "
|
|
168
|
+
f"model with an mlx_repo, or use --backend ollama")
|
|
169
|
+
if not ensure_mlx_lm(auto_install=auto_install):
|
|
170
|
+
raise ValueError(
|
|
171
|
+
"mlx-lm is not installed and could not be installed "
|
|
172
|
+
"automatically; run: pip install 'nvdc[mlx]' "
|
|
173
|
+
"(Linux/CUDA: pip install mlx-lm 'mlx[cuda]')")
|
|
174
|
+
self.store = store or ModelStore()
|
|
175
|
+
self.port = _free_port()
|
|
176
|
+
self._proc: Optional[subprocess.Popen] = None
|
|
177
|
+
self._commit = ""
|
|
178
|
+
self._model_dir: Optional[Path] = None
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def base_url(self) -> str:
|
|
182
|
+
return f"http://127.0.0.1:{self.port}"
|
|
183
|
+
|
|
184
|
+
async def list_models(self) -> List[str]:
|
|
185
|
+
return [self.model]
|
|
186
|
+
|
|
187
|
+
async def model_commitment(self, model: str) -> str:
|
|
188
|
+
return self._commit or self.store.commitment(self.model)
|
|
189
|
+
|
|
190
|
+
async def warm(self, model: str) -> None:
|
|
191
|
+
model_dir = await asyncio.to_thread(self.store.ensure, self.model, self.repo)
|
|
192
|
+
# Custody check: the commitment we are about to sign must describe
|
|
193
|
+
# the bytes on disk RIGHT NOW, not at download time.
|
|
194
|
+
if not await asyncio.to_thread(self.store.verify, self.model):
|
|
195
|
+
raise RuntimeError(
|
|
196
|
+
f"custody verification FAILED for {self.model}: weights on "
|
|
197
|
+
f"disk do not match this client's manifest — refusing to "
|
|
198
|
+
f"serve (delete {model_dir} to re-download)")
|
|
199
|
+
self._commit = self.store.commitment(self.model)
|
|
200
|
+
self._model_dir = model_dir
|
|
201
|
+
await self._start_server(model_dir)
|
|
202
|
+
|
|
203
|
+
async def _start_server(self, model_dir: Path) -> None:
|
|
204
|
+
if self._proc and self._proc.poll() is None:
|
|
205
|
+
return
|
|
206
|
+
log_path = model_dir / "server.log"
|
|
207
|
+
cmd = [sys.executable, "-m", "mlx_lm", "server",
|
|
208
|
+
"--model", str(model_dir), "--host", "127.0.0.1",
|
|
209
|
+
"--port", str(self.port)]
|
|
210
|
+
log.info("starting mlx_lm.server on port %d for %s", self.port, self.model)
|
|
211
|
+
with open(log_path, "ab") as lf:
|
|
212
|
+
self._proc = subprocess.Popen(cmd, stdout=lf, stderr=lf)
|
|
213
|
+
atexit.register(self.close)
|
|
214
|
+
deadline = time.time() + 300 # large models take a while to load
|
|
215
|
+
async with httpx.AsyncClient(timeout=5) as c:
|
|
216
|
+
while time.time() < deadline:
|
|
217
|
+
if self._proc.poll() is not None:
|
|
218
|
+
tail = ""
|
|
219
|
+
try:
|
|
220
|
+
tail = log_path.read_bytes()[-600:].decode(errors="replace")
|
|
221
|
+
except Exception:
|
|
222
|
+
pass
|
|
223
|
+
raise RuntimeError(f"mlx_lm.server exited at startup: {tail}")
|
|
224
|
+
try:
|
|
225
|
+
r = await c.get(f"{self.base_url}/health")
|
|
226
|
+
if r.status_code < 500:
|
|
227
|
+
break
|
|
228
|
+
except Exception:
|
|
229
|
+
pass
|
|
230
|
+
await asyncio.sleep(0.5)
|
|
231
|
+
else:
|
|
232
|
+
raise RuntimeError("mlx_lm.server did not become ready in time")
|
|
233
|
+
# Force the weights resident so the node is genuinely hot.
|
|
234
|
+
await self.chat_once({"model": self.model, "max_tokens": 1,
|
|
235
|
+
"messages": [{"role": "user", "content": "ok"}]})
|
|
236
|
+
|
|
237
|
+
def close(self) -> None:
|
|
238
|
+
if self._proc and self._proc.poll() is None:
|
|
239
|
+
self._proc.terminate()
|
|
240
|
+
try:
|
|
241
|
+
self._proc.wait(timeout=5)
|
|
242
|
+
except Exception:
|
|
243
|
+
self._proc.kill()
|
|
244
|
+
|
|
245
|
+
@staticmethod
|
|
246
|
+
def _error(status: int, text: str, model: str) -> str:
|
|
247
|
+
detail = text
|
|
248
|
+
try:
|
|
249
|
+
j = json.loads(text)
|
|
250
|
+
detail = (j.get("error") or j.get("message") or text)
|
|
251
|
+
if isinstance(detail, dict):
|
|
252
|
+
detail = detail.get("message", text)
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
return f"mlx error {status} for model '{model}': {str(detail)[:400]}"
|
|
256
|
+
|
|
257
|
+
def _dir(self) -> str:
|
|
258
|
+
return str(self._model_dir or self.store.dir_for(self.model))
|
|
259
|
+
|
|
260
|
+
async def chat_once(self, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
261
|
+
body = dict(body)
|
|
262
|
+
body["stream"] = False
|
|
263
|
+
# mlx_lm.server resolves the request's model as a repo/path to load;
|
|
264
|
+
# translate the canonical network id to the local store directory and
|
|
265
|
+
# restore the canonical id in the response.
|
|
266
|
+
body["model"] = self._dir()
|
|
267
|
+
async with httpx.AsyncClient(timeout=None) as c:
|
|
268
|
+
r = await c.post(f"{self.base_url}/v1/chat/completions", json=body)
|
|
269
|
+
if r.status_code >= 400:
|
|
270
|
+
raise RuntimeError(self._error(r.status_code, r.text, self.model))
|
|
271
|
+
result = r.json()
|
|
272
|
+
if isinstance(result, dict) and result.get("model"):
|
|
273
|
+
result["model"] = self.model
|
|
274
|
+
return result
|
|
275
|
+
|
|
276
|
+
async def chat_stream(self, body: Dict[str, Any]) -> AsyncIterator[str]:
|
|
277
|
+
body = dict(body)
|
|
278
|
+
body["stream"] = True
|
|
279
|
+
body["model"] = self._dir()
|
|
280
|
+
local_dir = self._dir()
|
|
281
|
+
async with httpx.AsyncClient(timeout=None) as c:
|
|
282
|
+
async with c.stream("POST", f"{self.base_url}/v1/chat/completions", json=body) as r:
|
|
283
|
+
if r.status_code >= 400:
|
|
284
|
+
await r.aread()
|
|
285
|
+
raise RuntimeError(self._error(r.status_code, r.text, self.model))
|
|
286
|
+
async for line in r.aiter_lines():
|
|
287
|
+
if line:
|
|
288
|
+
yield line.replace(local_dir, self.model)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class EchoBackend(Backend):
|
|
292
|
+
"""Zero-dependency backend that streams back a canned response. Used to test
|
|
293
|
+
the network plumbing without downloading a model."""
|
|
294
|
+
|
|
295
|
+
name = "echo"
|
|
296
|
+
|
|
297
|
+
def __init__(self, model: str = "echo-1"):
|
|
298
|
+
self.model = model
|
|
299
|
+
|
|
300
|
+
async def list_models(self) -> List[str]:
|
|
301
|
+
return [self.model]
|
|
302
|
+
|
|
303
|
+
def _reply_text(self, body: Dict[str, Any]) -> str:
|
|
304
|
+
msgs = body.get("messages", [])
|
|
305
|
+
last = msgs[-1]["content"] if msgs else ""
|
|
306
|
+
return f"[echo] You said: {last}"
|
|
307
|
+
|
|
308
|
+
async def chat_once(self, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
309
|
+
text = self._reply_text(body)
|
|
310
|
+
return {
|
|
311
|
+
"id": f"chatcmpl-{uuid.uuid4().hex[:24]}",
|
|
312
|
+
"object": "chat.completion",
|
|
313
|
+
"created": int(time.time()),
|
|
314
|
+
"model": body.get("model", self.model),
|
|
315
|
+
"choices": [{
|
|
316
|
+
"index": 0,
|
|
317
|
+
"message": {"role": "assistant", "content": text},
|
|
318
|
+
"finish_reason": "stop",
|
|
319
|
+
}],
|
|
320
|
+
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async def chat_stream(self, body: Dict[str, Any]) -> AsyncIterator[str]:
|
|
324
|
+
cid = f"chatcmpl-{uuid.uuid4().hex[:24]}"
|
|
325
|
+
model = body.get("model", self.model)
|
|
326
|
+
created = int(time.time())
|
|
327
|
+
|
|
328
|
+
def frame(delta: Dict[str, Any], finish=None) -> str:
|
|
329
|
+
obj = {
|
|
330
|
+
"id": cid, "object": "chat.completion.chunk", "created": created,
|
|
331
|
+
"model": model,
|
|
332
|
+
"choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
|
|
333
|
+
}
|
|
334
|
+
return "data: " + json.dumps(obj, separators=(",", ":"))
|
|
335
|
+
|
|
336
|
+
yield frame({"role": "assistant"})
|
|
337
|
+
for word in self._reply_text(body).split(" "):
|
|
338
|
+
yield frame({"content": word + " "})
|
|
339
|
+
yield frame({}, finish="stop")
|
|
340
|
+
yield "data: [DONE]"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def make_backend(kind: str, model: str = "", ollama_url: str = "http://localhost:11434") -> Backend:
|
|
344
|
+
if kind == "echo":
|
|
345
|
+
return EchoBackend()
|
|
346
|
+
if kind == "ollama":
|
|
347
|
+
return OllamaBackend(base_url=ollama_url, default_model=model)
|
|
348
|
+
if kind == "mlx":
|
|
349
|
+
return MlxBackend(model=model)
|
|
350
|
+
raise ValueError(f"unknown backend: {kind}")
|