nvdc 0.2.3__tar.gz → 0.2.4__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.
Files changed (46) hide show
  1. {nvdc-0.2.3 → nvdc-0.2.4}/PKG-INFO +2 -1
  2. {nvdc-0.2.3 → nvdc-0.2.4}/pyproject.toml +3 -1
  3. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc/__init__.py +1 -1
  4. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc/runtime.py +122 -5
  5. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc.egg-info/PKG-INFO +2 -1
  6. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc.egg-info/SOURCES.txt +1 -0
  7. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc.egg-info/requires.txt +1 -0
  8. nvdc-0.2.4/tests/test_ollama_autoinstall.py +83 -0
  9. {nvdc-0.2.3 → nvdc-0.2.4}/README.md +0 -0
  10. {nvdc-0.2.3 → nvdc-0.2.4}/setup.cfg +0 -0
  11. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc/accounts.py +0 -0
  12. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc/agent.py +0 -0
  13. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc/app.py +0 -0
  14. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc/attestation.py +0 -0
  15. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc/catalog.py +0 -0
  16. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc/cli.py +0 -0
  17. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc/config.py +0 -0
  18. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc/coordinator.py +0 -0
  19. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc/gpu.py +0 -0
  20. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc/hardware.py +0 -0
  21. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc/house.py +0 -0
  22. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc/inference.py +0 -0
  23. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc/keys.py +0 -0
  24. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc/models.py +0 -0
  25. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc/payments.py +0 -0
  26. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc/protocol.py +0 -0
  27. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc/storage.py +0 -0
  28. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc/store.py +0 -0
  29. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc/update.py +0 -0
  30. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc/wallet.py +0 -0
  31. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc/web/index.html +0 -0
  32. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc.egg-info/dependency_links.txt +0 -0
  33. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc.egg-info/entry_points.txt +0 -0
  34. {nvdc-0.2.3 → nvdc-0.2.4}/src/nvdc.egg-info/top_level.txt +0 -0
  35. {nvdc-0.2.3 → nvdc-0.2.4}/tests/test_accounts_auth.py +0 -0
  36. {nvdc-0.2.3 → nvdc-0.2.4}/tests/test_coordinator_security.py +0 -0
  37. {nvdc-0.2.3 → nvdc-0.2.4}/tests/test_house_node.py +0 -0
  38. {nvdc-0.2.3 → nvdc-0.2.4}/tests/test_mlx_custody.py +0 -0
  39. {nvdc-0.2.3 → nvdc-0.2.4}/tests/test_model_attestation.py +0 -0
  40. {nvdc-0.2.3 → nvdc-0.2.4}/tests/test_persistence.py +0 -0
  41. {nvdc-0.2.3 → nvdc-0.2.4}/tests/test_self_serve.py +0 -0
  42. {nvdc-0.2.3 → nvdc-0.2.4}/tests/test_self_update.py +0 -0
  43. {nvdc-0.2.3 → nvdc-0.2.4}/tests/test_split_pricing.py +0 -0
  44. {nvdc-0.2.3 → nvdc-0.2.4}/tests/test_storage_money.py +0 -0
  45. {nvdc-0.2.3 → nvdc-0.2.4}/tests/test_stripe_webhook.py +0 -0
  46. {nvdc-0.2.3 → nvdc-0.2.4}/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
3
+ Version: 0.2.4
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
@@ -14,6 +14,7 @@ Requires-Dist: cryptography>=42.0
14
14
  Requires-Dist: nvidia-ml-py>=12.535.77
15
15
  Requires-Dist: redis>=5.0
16
16
  Requires-Dist: stripe>=9.0
17
+ Requires-Dist: zstandard>=0.22
17
18
  Provides-Extra: attestation
18
19
  Requires-Dist: nv-attestation-sdk>=2.7.0; extra == "attestation"
19
20
  Requires-Dist: nv-local-gpu-verifier>=2.7.0; extra == "attestation"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nvdc"
3
- version = "0.2.3"
3
+ version = "0.2.4"
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"
@@ -15,6 +15,8 @@ dependencies = [
15
15
  "nvidia-ml-py>=12.535.77",
16
16
  "redis>=5.0",
17
17
  "stripe>=9.0",
18
+ # Ollama distributes its runtime as .tar.zst; used by the auto-installer.
19
+ "zstandard>=0.22",
18
20
  ]
19
21
 
20
22
  [project.optional-dependencies]
@@ -1,3 +1,3 @@
1
1
  """nvdc: bring your GPU onto the network as a verifiable, OpenAI-compatible inference node."""
2
2
 
3
- __version__ = "0.2.3"
3
+ __version__ = "0.2.4"
@@ -14,10 +14,14 @@ from __future__ import annotations
14
14
 
15
15
  import asyncio
16
16
  import logging
17
+ import os
18
+ import platform
17
19
  import shutil
18
20
  import subprocess
21
+ import sys
19
22
  import time
20
23
  from dataclasses import asdict, dataclass, field
24
+ from pathlib import Path
21
25
  from typing import Optional
22
26
 
23
27
  import httpx
@@ -36,6 +40,19 @@ log = logging.getLogger("nvdc.runtime")
36
40
 
37
41
  OLLAMA_URL = "http://localhost:11434"
38
42
 
43
+ # User-space Ollama runtime, installed by the client itself when no system
44
+ # Ollama exists (no sudo required — the official tarball is self-contained).
45
+ OLLAMA_INSTALL_DIR = Path(os.path.expanduser("~/.nvdc/ollama"))
46
+
47
+
48
+ def _ollama_arch() -> str:
49
+ m = platform.machine().lower()
50
+ if m in ("x86_64", "amd64"):
51
+ return "amd64"
52
+ if m in ("aarch64", "arm64"):
53
+ return "arm64"
54
+ return ""
55
+
39
56
 
40
57
  @dataclass
41
58
  class LoadState:
@@ -259,8 +276,11 @@ class NodeRuntime:
259
276
  return tip + "."
260
277
  if any(k in m for k in ("not found", "no such model", "pull")):
261
278
  return "Model couldn't be fetched — check your internet connection and try loading again."
262
- if any(k in m for k in ("connection", "refused", "not installed", "could not start")):
263
- return "Ollama isn't running. Install it from https://ollama.com and try again."
279
+ if "not installed" in m:
280
+ return ("On Linux the client installs Ollama automatically retry the load. "
281
+ "On macOS: brew install --cask ollama (or use the MLX backend).")
282
+ if any(k in m for k in ("connection", "refused", "could not start")):
283
+ return "Ollama isn't running. Retry the load, or start it manually with 'ollama serve'."
264
284
  return "Try a smaller model, or verify Ollama is installed and running, then reload."
265
285
 
266
286
  async def _ollama_preflight(self, model_id: str):
@@ -290,6 +310,97 @@ class NodeRuntime:
290
310
  self.load.detail = "warming model into memory (mock)"
291
311
  await asyncio.sleep(0.6)
292
312
 
313
+ def _ollama_bin(self) -> Optional[str]:
314
+ """The Ollama binary to use: system install, else our user-space one."""
315
+ found = shutil.which("ollama")
316
+ if found:
317
+ return found
318
+ local = OLLAMA_INSTALL_DIR / "bin" / "ollama"
319
+ return str(local) if local.exists() else None
320
+
321
+ async def _ollama_install(self):
322
+ """Install the Ollama runtime user-space — the app owns its deps.
323
+
324
+ Uses the official self-contained tarball, extracted under ~/.nvdc —
325
+ no sudo, no system Python/packages touched. Linux only; macOS users
326
+ get it via the installer script (brew cask) or use the MLX backend.
327
+ """
328
+ arch = _ollama_arch()
329
+ if sys.platform != "linux" or not arch:
330
+ hint = (" On macOS: brew install --cask ollama"
331
+ if sys.platform == "darwin" else "")
332
+ raise RuntimeError(
333
+ "Ollama is not installed. Install it from https://ollama.com" + hint)
334
+ self.load.status = "pulling"
335
+ self.load.progress = 0
336
+ self.load.detail = "installing the Ollama runtime (one-time setup)…"
337
+ OLLAMA_INSTALL_DIR.mkdir(parents=True, exist_ok=True)
338
+ # Ollama distributes .tar.zst today; keep .tgz as a legacy fallback.
339
+ tmp = None
340
+ last_err: Optional[Exception] = None
341
+ for suffix in (".tar.zst", ".tgz"):
342
+ url = f"https://ollama.com/download/ollama-linux-{arch}{suffix}"
343
+ candidate = OLLAMA_INSTALL_DIR / (".download" + suffix)
344
+ try:
345
+ log.info("installing the Ollama runtime from %s", url)
346
+ await self._download(url, candidate)
347
+ tmp = candidate
348
+ break
349
+ except Exception as e:
350
+ last_err = e
351
+ candidate.unlink(missing_ok=True)
352
+ if tmp is None:
353
+ raise RuntimeError(f"could not download the Ollama runtime: {last_err}")
354
+ self.load.detail = "unpacking the Ollama runtime…"
355
+ await asyncio.to_thread(self._extract_runtime, tmp)
356
+ tmp.unlink(missing_ok=True)
357
+ # Best-effort: expose `ollama` on the user's PATH for terminal use too.
358
+ try:
359
+ local_bin = Path(os.path.expanduser("~/.local/bin"))
360
+ local_bin.mkdir(parents=True, exist_ok=True)
361
+ link = local_bin / "ollama"
362
+ if not link.exists():
363
+ link.symlink_to(OLLAMA_INSTALL_DIR / "bin" / "ollama")
364
+ except Exception:
365
+ pass
366
+ log.info("Ollama runtime installed to %s", OLLAMA_INSTALL_DIR)
367
+
368
+ async def _download(self, url: str, dest: Path):
369
+ async with httpx.AsyncClient(timeout=None, follow_redirects=True) as c:
370
+ async with c.stream("GET", url) as r:
371
+ r.raise_for_status()
372
+ total = int(r.headers.get("content-length") or 0)
373
+ done = 0
374
+ with open(dest, "wb") as f:
375
+ async for chunk in r.aiter_bytes(1024 * 1024):
376
+ f.write(chunk)
377
+ done += len(chunk)
378
+ if total:
379
+ self.load.progress = int(done * 100 / total)
380
+ self.load.detail = (
381
+ f"installing the Ollama runtime… "
382
+ f"{done // (1024*1024)} / {total // (1024*1024)} MB")
383
+
384
+ @staticmethod
385
+ def _extract_runtime(archive: Path):
386
+ import tarfile
387
+
388
+ def _extract(t: tarfile.TarFile):
389
+ try:
390
+ t.extractall(OLLAMA_INSTALL_DIR, filter="data")
391
+ except TypeError: # Python < 3.12 without the filter parameter
392
+ t.extractall(OLLAMA_INSTALL_DIR)
393
+
394
+ if archive.name.endswith(".tar.zst"):
395
+ import zstandard
396
+ with open(archive, "rb") as f:
397
+ with zstandard.ZstdDecompressor().stream_reader(f) as reader:
398
+ with tarfile.open(fileobj=reader, mode="r|") as t:
399
+ _extract(t)
400
+ else:
401
+ with tarfile.open(archive, "r:gz") as t:
402
+ _extract(t)
403
+
293
404
  async def _ollama_ensure_server(self):
294
405
  try:
295
406
  async with httpx.AsyncClient(timeout=3) as c:
@@ -297,9 +408,15 @@ class NodeRuntime:
297
408
  return
298
409
  except Exception:
299
410
  pass
300
- if shutil.which("ollama") is None:
301
- raise RuntimeError("Ollama is not installed. Install it from https://ollama.com")
302
- subprocess.Popen(["ollama", "serve"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
411
+ binary = self._ollama_bin()
412
+ if binary is None:
413
+ # The app owns its dependencies: install the runtime, don't bail.
414
+ await self._ollama_install()
415
+ binary = self._ollama_bin()
416
+ if binary is None:
417
+ raise RuntimeError("Ollama install completed but the binary "
418
+ "was not found — please report this")
419
+ subprocess.Popen([binary, "serve"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
303
420
  for _ in range(20):
304
421
  await asyncio.sleep(0.5)
305
422
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nvdc
3
- Version: 0.2.3
3
+ Version: 0.2.4
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
@@ -14,6 +14,7 @@ Requires-Dist: cryptography>=42.0
14
14
  Requires-Dist: nvidia-ml-py>=12.535.77
15
15
  Requires-Dist: redis>=5.0
16
16
  Requires-Dist: stripe>=9.0
17
+ Requires-Dist: zstandard>=0.22
17
18
  Provides-Extra: attestation
18
19
  Requires-Dist: nv-attestation-sdk>=2.7.0; extra == "attestation"
19
20
  Requires-Dist: nv-local-gpu-verifier>=2.7.0; extra == "attestation"
@@ -34,6 +34,7 @@ tests/test_coordinator_security.py
34
34
  tests/test_house_node.py
35
35
  tests/test_mlx_custody.py
36
36
  tests/test_model_attestation.py
37
+ tests/test_ollama_autoinstall.py
37
38
  tests/test_persistence.py
38
39
  tests/test_self_serve.py
39
40
  tests/test_self_update.py
@@ -6,6 +6,7 @@ cryptography>=42.0
6
6
  nvidia-ml-py>=12.535.77
7
7
  redis>=5.0
8
8
  stripe>=9.0
9
+ zstandard>=0.22
9
10
 
10
11
  [attestation]
11
12
  nv-attestation-sdk>=2.7.0
@@ -0,0 +1,83 @@
1
+ """The client installs its own Ollama runtime instead of bailing out."""
2
+
3
+ import asyncio
4
+
5
+ import pytest
6
+
7
+ from nvdc import runtime as rt
8
+
9
+
10
+ def test_arch_mapping(monkeypatch):
11
+ cases = {"x86_64": "amd64", "amd64": "amd64",
12
+ "aarch64": "arm64", "arm64": "arm64", "riscv64": ""}
13
+ for machine, expect in cases.items():
14
+ monkeypatch.setattr(rt.platform, "machine", lambda m=machine: m)
15
+ assert rt._ollama_arch() == expect
16
+
17
+
18
+ @pytest.fixture()
19
+ def runtime(monkeypatch, tmp_path):
20
+ monkeypatch.setenv("HOME", str(tmp_path))
21
+ return rt.NodeRuntime(name="t", mock=True)
22
+
23
+
24
+ def test_ollama_bin_prefers_system_then_local(runtime, monkeypatch, tmp_path):
25
+ monkeypatch.setattr(rt.shutil, "which", lambda _: "/usr/bin/ollama")
26
+ assert runtime._ollama_bin() == "/usr/bin/ollama"
27
+
28
+ monkeypatch.setattr(rt.shutil, "which", lambda _: None)
29
+ monkeypatch.setattr(rt, "OLLAMA_INSTALL_DIR", tmp_path / "ollama")
30
+ assert runtime._ollama_bin() is None
31
+ binpath = tmp_path / "ollama" / "bin"
32
+ binpath.mkdir(parents=True)
33
+ (binpath / "ollama").write_text("#!/bin/sh\n")
34
+ assert runtime._ollama_bin() == str(binpath / "ollama")
35
+
36
+
37
+ def test_ensure_server_installs_instead_of_bailing(runtime, monkeypatch):
38
+ """The reported bug: 'Ollama is not installed' was a dead end."""
39
+ class _Boom(Exception):
40
+ pass
41
+
42
+ async def fake_install():
43
+ raise _Boom("install path reached")
44
+
45
+ monkeypatch.setattr(rt.shutil, "which", lambda _: None)
46
+ monkeypatch.setattr(rt, "OLLAMA_INSTALL_DIR", rt.Path("/nonexistent-nvdc-test"))
47
+ monkeypatch.setattr(runtime, "_ollama_install", fake_install)
48
+ with pytest.raises(_Boom):
49
+ asyncio.run(runtime._ollama_ensure_server())
50
+
51
+
52
+ def test_extract_runtime_handles_tar_zst(monkeypatch, tmp_path):
53
+ import io
54
+ import tarfile
55
+ import zstandard
56
+
57
+ # Build a tiny ollama-style .tar.zst (bin/ollama with exec bit).
58
+ buf = io.BytesIO()
59
+ with tarfile.open(fileobj=buf, mode="w") as t:
60
+ info = tarfile.TarInfo("bin/ollama")
61
+ payload = b"#!/bin/sh\necho ollama\n"
62
+ info.size = len(payload)
63
+ info.mode = 0o755
64
+ t.addfile(info, io.BytesIO(payload))
65
+ archive = tmp_path / ".download.tar.zst"
66
+ archive.write_bytes(zstandard.ZstdCompressor().compress(buf.getvalue()))
67
+
68
+ monkeypatch.setattr(rt, "OLLAMA_INSTALL_DIR", tmp_path / "ollama")
69
+ (tmp_path / "ollama").mkdir()
70
+ rt.NodeRuntime._extract_runtime(archive)
71
+ binary = tmp_path / "ollama" / "bin" / "ollama"
72
+ assert binary.exists()
73
+ assert binary.stat().st_mode & 0o111 # executable
74
+
75
+
76
+ def test_install_refuses_unsupported_platform(runtime, monkeypatch):
77
+ monkeypatch.setattr(rt.sys, "platform", "darwin")
78
+ with pytest.raises(RuntimeError, match="brew install --cask ollama"):
79
+ asyncio.run(runtime._ollama_install())
80
+ monkeypatch.setattr(rt.sys, "platform", "linux")
81
+ monkeypatch.setattr(rt.platform, "machine", lambda: "riscv64")
82
+ with pytest.raises(RuntimeError, match="ollama.com"):
83
+ asyncio.run(runtime._ollama_install())
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes