zkai-cli 0.5.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.
- zkai_cli/__init__.py +0 -0
- zkai_cli/__main__.py +4 -0
- zkai_cli/docker.py +271 -0
- zkai_cli/keys.py +25 -0
- zkai_cli/main.py +138 -0
- zkai_cli/register.py +259 -0
- zkai_cli/setup.py +130 -0
- zkai_cli/util.py +154 -0
- zkai_cli-0.5.0.dist-info/METADATA +102 -0
- zkai_cli-0.5.0.dist-info/RECORD +12 -0
- zkai_cli-0.5.0.dist-info/WHEEL +4 -0
- zkai_cli-0.5.0.dist-info/entry_points.txt +2 -0
zkai_cli/__init__.py
ADDED
|
File without changes
|
zkai_cli/__main__.py
ADDED
zkai_cli/docker.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Docker Compose operations: start, stop, restart, logs, status."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import tarfile
|
|
5
|
+
import time
|
|
6
|
+
import urllib.request
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich import box
|
|
14
|
+
|
|
15
|
+
from zkai_cli.util import (
|
|
16
|
+
compose_dir, deploy_dir, console, err_console, find_repo_root, ensure_repo,
|
|
17
|
+
require_docker, run, stream,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
SERVICES = ("enclave", "bridge")
|
|
21
|
+
|
|
22
|
+
GITHUB_REPO = "Eshan276/zkai"
|
|
23
|
+
COMPILED_ASSET = "compiled.tar.gz"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _compose(repo: Path, *args: str, stream_output: bool = True) -> subprocess.CompletedProcess:
|
|
27
|
+
cmd = ["docker", "compose", *args]
|
|
28
|
+
cwd = compose_dir(repo)
|
|
29
|
+
if stream_output:
|
|
30
|
+
stream(cmd, cwd=cwd)
|
|
31
|
+
return None # type: ignore
|
|
32
|
+
return run(cmd, cwd=cwd, capture=True, check=False)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── start ─────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
def start(repo_dir: str | None, build: bool = False, follow: bool = False):
|
|
38
|
+
require_docker()
|
|
39
|
+
repo = ensure_repo(repo_dir)
|
|
40
|
+
cwd = compose_dir(repo)
|
|
41
|
+
|
|
42
|
+
_ensure_compiled_artifacts(repo)
|
|
43
|
+
|
|
44
|
+
if build:
|
|
45
|
+
console.print("[bold]Building enclave image...[/bold]")
|
|
46
|
+
stream(["docker", "compose", "build", "enclave"], cwd=cwd)
|
|
47
|
+
|
|
48
|
+
console.print("[bold]Starting ZKai containers...[/bold]")
|
|
49
|
+
stream(["docker", "compose", "up", "-d"], cwd=cwd)
|
|
50
|
+
console.print()
|
|
51
|
+
console.print("[green]Containers started.[/green] Bridge wallet sync takes 2-5 minutes on first boot.")
|
|
52
|
+
console.print("Run [bold]zkai status[/bold] to check progress.")
|
|
53
|
+
console.print("Run [bold]zkai logs[/bold] to watch logs.")
|
|
54
|
+
|
|
55
|
+
if follow:
|
|
56
|
+
logs(repo_dir, service=None, lines=30, follow=True)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ── stop ──────────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
def stop(repo_dir: str | None):
|
|
62
|
+
require_docker()
|
|
63
|
+
repo = find_repo_root(repo_dir)
|
|
64
|
+
console.print("[bold]Stopping ZKai containers...[/bold]")
|
|
65
|
+
_compose(repo, "down")
|
|
66
|
+
console.print("[green]Stopped.[/green]")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ── restart ───────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
def restart(repo_dir: str | None, service: str | None):
|
|
72
|
+
require_docker()
|
|
73
|
+
repo = find_repo_root(repo_dir)
|
|
74
|
+
targets = _resolve_service(service)
|
|
75
|
+
console.print(f"[bold]Restarting {', '.join(targets)}...[/bold]")
|
|
76
|
+
_compose(repo, "restart", *targets)
|
|
77
|
+
console.print("[green]Done.[/green]")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── logs ──────────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
def logs(repo_dir: str | None, service: str | None, lines: int = 50, follow: bool = False):
|
|
83
|
+
require_docker()
|
|
84
|
+
repo = find_repo_root(repo_dir)
|
|
85
|
+
targets = _resolve_service(service)
|
|
86
|
+
cmd = ["docker", "compose", "logs", f"--tail={lines}"]
|
|
87
|
+
if follow:
|
|
88
|
+
cmd.append("-f")
|
|
89
|
+
cmd.extend(targets)
|
|
90
|
+
stream(cmd, cwd=compose_dir(repo))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ── status ────────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
def status(repo_dir: str | None):
|
|
96
|
+
require_docker()
|
|
97
|
+
repo = find_repo_root(repo_dir)
|
|
98
|
+
|
|
99
|
+
# Container states
|
|
100
|
+
r = _compose(repo, "ps", "--format", "json", stream_output=False)
|
|
101
|
+
containers = _parse_ps(r.stdout if r else "")
|
|
102
|
+
|
|
103
|
+
table = Table(title="ZKai Node Status", box=box.ROUNDED, show_lines=True)
|
|
104
|
+
table.add_column("Container", style="bold")
|
|
105
|
+
table.add_column("State")
|
|
106
|
+
table.add_column("Health")
|
|
107
|
+
table.add_column("Ports")
|
|
108
|
+
|
|
109
|
+
for c in containers:
|
|
110
|
+
state_color = "green" if c["state"] == "running" else "red"
|
|
111
|
+
health_color = {
|
|
112
|
+
"healthy": "green",
|
|
113
|
+
"starting": "yellow",
|
|
114
|
+
"unhealthy": "red",
|
|
115
|
+
}.get(c["health"], "dim")
|
|
116
|
+
table.add_row(
|
|
117
|
+
c["name"],
|
|
118
|
+
f"[{state_color}]{c['state']}[/{state_color}]",
|
|
119
|
+
f"[{health_color}]{c['health'] or '—'}[/{health_color}]",
|
|
120
|
+
c["ports"] or "—",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
console.print(table)
|
|
124
|
+
|
|
125
|
+
# Bridge health
|
|
126
|
+
_print_bridge_health()
|
|
127
|
+
|
|
128
|
+
# Enclave health
|
|
129
|
+
_print_enclave_health()
|
|
130
|
+
|
|
131
|
+
# Dashboard link
|
|
132
|
+
_print_dashboard_link(repo)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _print_bridge_health():
|
|
136
|
+
try:
|
|
137
|
+
r = requests.get("http://127.0.0.1:7300/health", timeout=3)
|
|
138
|
+
data = r.json()
|
|
139
|
+
synced = data.get("synced", False)
|
|
140
|
+
addr = data.get("address", "unknown")
|
|
141
|
+
if synced:
|
|
142
|
+
console.print(f"[green]Bridge:[/green] synced | address: {addr}")
|
|
143
|
+
else:
|
|
144
|
+
console.print("[yellow]Bridge:[/yellow] syncing (wallet not yet synced — wait 2-5 min)")
|
|
145
|
+
except Exception:
|
|
146
|
+
console.print("[dim]Bridge:[/dim] not reachable on port 7300 (container may still be starting)")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _print_enclave_health():
|
|
150
|
+
try:
|
|
151
|
+
r = requests.get("http://127.0.0.1:8080/health", timeout=3)
|
|
152
|
+
data = r.json()
|
|
153
|
+
mode = data.get("enclave_mode", "unknown")
|
|
154
|
+
console.print(f"[green]Enclave:[/green] ok | mode: {mode}")
|
|
155
|
+
except Exception:
|
|
156
|
+
console.print("[dim]Enclave:[/dim] not reachable on port 8080 (container may still be starting)")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _print_dashboard_link(repo: Path):
|
|
160
|
+
import json as _json
|
|
161
|
+
pid_file = compose_dir(repo) / ".provider_id"
|
|
162
|
+
if not pid_file.exists():
|
|
163
|
+
return
|
|
164
|
+
try:
|
|
165
|
+
data = _json.loads(pid_file.read_text())
|
|
166
|
+
provider_id = data.get("provider_id", "")
|
|
167
|
+
if provider_id:
|
|
168
|
+
url = f"https://zkai-ether-og.vercel.app/provider_dashboard?id={provider_id}"
|
|
169
|
+
console.print(f"\n[bold]Provider Dashboard:[/bold] [link={url}][violet]{url}[/violet][/link]")
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _parse_ps(raw: str) -> list[dict]:
|
|
175
|
+
"""Parse `docker compose ps --format json` output (one JSON object per line)."""
|
|
176
|
+
import json
|
|
177
|
+
results = []
|
|
178
|
+
for line in raw.strip().splitlines():
|
|
179
|
+
line = line.strip()
|
|
180
|
+
if not line:
|
|
181
|
+
continue
|
|
182
|
+
try:
|
|
183
|
+
obj = json.loads(line)
|
|
184
|
+
results.append({
|
|
185
|
+
"name": obj.get("Name", obj.get("Service", "?")),
|
|
186
|
+
"state": obj.get("State", "?").lower(),
|
|
187
|
+
"health": obj.get("Health", "").lower() or None,
|
|
188
|
+
"ports": _fmt_ports(obj.get("Publishers") or obj.get("Ports") or []),
|
|
189
|
+
})
|
|
190
|
+
except Exception:
|
|
191
|
+
continue
|
|
192
|
+
return results
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _fmt_ports(ports) -> str:
|
|
196
|
+
if isinstance(ports, str):
|
|
197
|
+
return ports
|
|
198
|
+
if isinstance(ports, list):
|
|
199
|
+
out = []
|
|
200
|
+
for p in ports:
|
|
201
|
+
if isinstance(p, dict):
|
|
202
|
+
pub = p.get("PublishedPort", "")
|
|
203
|
+
tgt = p.get("TargetPort", "")
|
|
204
|
+
if pub and tgt:
|
|
205
|
+
out.append(f"{pub}→{tgt}")
|
|
206
|
+
return ", ".join(out) if out else ""
|
|
207
|
+
return ""
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _resolve_service(service: str | None) -> list[str]:
|
|
211
|
+
if service is None:
|
|
212
|
+
return list(SERVICES)
|
|
213
|
+
s = service.lower()
|
|
214
|
+
if s not in SERVICES:
|
|
215
|
+
err_console.print(f"[red]Unknown service '{service}'.[/red] Choose: {', '.join(SERVICES)}")
|
|
216
|
+
raise typer.Exit(1)
|
|
217
|
+
return [s]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ── compiled artifact bootstrap ───────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
def _ensure_compiled_artifacts(repo: Path):
|
|
223
|
+
"""Download and extract compiled.tar.gz from the latest release if deploy/compiled/ is missing."""
|
|
224
|
+
compiled = deploy_dir(repo) / "compiled"
|
|
225
|
+
marker = compiled / "ProviderRegistry" / "contract" / "index.js"
|
|
226
|
+
if marker.exists():
|
|
227
|
+
return # already present
|
|
228
|
+
|
|
229
|
+
console.print("[bold]Compiled contract artifacts not found — downloading from release...[/bold]")
|
|
230
|
+
|
|
231
|
+
url = _get_compiled_download_url()
|
|
232
|
+
if not url:
|
|
233
|
+
err_console.print(
|
|
234
|
+
"[red]Could not find compiled.tar.gz in the latest release.[/red]\n"
|
|
235
|
+
"Check https://github.com/Eshan276/zkai/releases or run the deploy script manually."
|
|
236
|
+
)
|
|
237
|
+
raise typer.Exit(1)
|
|
238
|
+
|
|
239
|
+
console.print(f" Downloading {url} ...")
|
|
240
|
+
tmp = repo / ".build-tmp" / COMPILED_ASSET
|
|
241
|
+
tmp.parent.mkdir(parents=True, exist_ok=True)
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
urllib.request.urlretrieve(url, tmp)
|
|
245
|
+
except Exception as e:
|
|
246
|
+
err_console.print(f"[red]Download failed:[/red] {e}")
|
|
247
|
+
raise typer.Exit(1)
|
|
248
|
+
|
|
249
|
+
console.print(" Extracting...")
|
|
250
|
+
compiled.mkdir(parents=True, exist_ok=True)
|
|
251
|
+
with tarfile.open(tmp, "r:gz") as tf:
|
|
252
|
+
# Archive is: compiled/<contract>/... — extract one level into deploy/
|
|
253
|
+
tf.extractall(deploy_dir(repo))
|
|
254
|
+
|
|
255
|
+
tmp.unlink(missing_ok=True)
|
|
256
|
+
console.print("[green]Compiled artifacts ready.[/green]")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _get_compiled_download_url() -> str | None:
|
|
260
|
+
"""Fetch the compiled.tar.gz download URL from the latest GitHub release."""
|
|
261
|
+
import json
|
|
262
|
+
api_url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
|
|
263
|
+
try:
|
|
264
|
+
with urllib.request.urlopen(api_url, timeout=10) as resp:
|
|
265
|
+
data = json.loads(resp.read())
|
|
266
|
+
for asset in data.get("assets", []):
|
|
267
|
+
if asset["name"] == COMPILED_ASSET:
|
|
268
|
+
return asset["browser_download_url"]
|
|
269
|
+
except Exception:
|
|
270
|
+
pass
|
|
271
|
+
return None
|
zkai_cli/keys.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
zkai keys — API key management.
|
|
3
|
+
|
|
4
|
+
API keys are now managed centrally via the ZKai dashboard.
|
|
5
|
+
Consumers connect their EVM wallet (MetaMask) and generate keys there.
|
|
6
|
+
Enclaves verify keys against the central auth server (ZKAI_AUTH_URL).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
|
|
12
|
+
from zkai_cli.util import console
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run(action: str, key_value: str | None, repo_dir: str | None, count: int):
|
|
16
|
+
console.print(Panel(
|
|
17
|
+
"[bold]API keys are managed via the ZKai dashboard.[/bold]\n\n"
|
|
18
|
+
"Consumers connect their EVM wallet (MetaMask) at the dashboard URL\n"
|
|
19
|
+
"and generate API keys from there.\n\n"
|
|
20
|
+
"Your enclave verifies keys automatically via [cyan]ZKAI_AUTH_URL[/cyan].\n\n"
|
|
21
|
+
"To update the auth server URL: [bold cyan]zkai init[/bold cyan]",
|
|
22
|
+
title="[yellow]zkai keys[/yellow]",
|
|
23
|
+
border_style="yellow",
|
|
24
|
+
))
|
|
25
|
+
raise typer.Exit(0)
|
zkai_cli/main.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
zkai CLI — provider node management tool.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
zkai init Guided first-time setup (EVM key, .env, relay config)
|
|
6
|
+
zkai start Start enclave + bridge (docker compose up -d)
|
|
7
|
+
zkai stop Stop all containers
|
|
8
|
+
zkai restart Restart containers
|
|
9
|
+
zkai logs Tail logs (enclave, bridge, or both)
|
|
10
|
+
zkai status Show container + wallet health
|
|
11
|
+
zkai register Register provider on-chain (one-time)
|
|
12
|
+
zkai deregister Remove provider from on-chain registry
|
|
13
|
+
zkai keys Show how API keys work (managed via dashboard)
|
|
14
|
+
zkai info Print provider ID, endpoint, address
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
|
|
20
|
+
from zkai_cli import setup as _setup
|
|
21
|
+
from zkai_cli import docker as _docker
|
|
22
|
+
from zkai_cli import register as _register
|
|
23
|
+
from zkai_cli import keys as _keys
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(
|
|
26
|
+
name="zkai",
|
|
27
|
+
help="ZKai provider node — setup, manage, and register your AI inference node.",
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
add_completion=False,
|
|
30
|
+
)
|
|
31
|
+
console = Console()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── Init / setup ──────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
@app.command()
|
|
37
|
+
def init(
|
|
38
|
+
repo_dir: str = typer.Option(
|
|
39
|
+
None, "--dir", "-d",
|
|
40
|
+
help="Path to the zkai repo root (auto-detected if omitted)",
|
|
41
|
+
),
|
|
42
|
+
):
|
|
43
|
+
"""Guided first-time setup: EVM private key, relay config, provider/.env."""
|
|
44
|
+
_setup.run_init(repo_dir)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── Lifecycle ─────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
@app.command()
|
|
50
|
+
def start(
|
|
51
|
+
repo_dir: str = typer.Option(None, "--dir", "-d", help="zkai repo root"),
|
|
52
|
+
build: bool = typer.Option(False, "--build", help="Rebuild enclave image before starting"),
|
|
53
|
+
follow: bool = typer.Option(False, "--logs", "-l", help="Tail logs after starting"),
|
|
54
|
+
):
|
|
55
|
+
"""Start enclave + bridge containers."""
|
|
56
|
+
_docker.start(repo_dir, build=build, follow=follow)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command()
|
|
60
|
+
def stop(
|
|
61
|
+
repo_dir: str = typer.Option(None, "--dir", "-d", help="zkai repo root"),
|
|
62
|
+
):
|
|
63
|
+
"""Stop all ZKai containers."""
|
|
64
|
+
_docker.stop(repo_dir)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command()
|
|
68
|
+
def restart(
|
|
69
|
+
repo_dir: str = typer.Option(None, "--dir", "-d", help="zkai repo root"),
|
|
70
|
+
service: str = typer.Argument(None, help="Service to restart: enclave, bridge, or both (default)"),
|
|
71
|
+
):
|
|
72
|
+
"""Restart containers (or a specific service)."""
|
|
73
|
+
_docker.restart(repo_dir, service)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command()
|
|
77
|
+
def logs(
|
|
78
|
+
repo_dir: str = typer.Option(None, "--dir", "-d", help="zkai repo root"),
|
|
79
|
+
service: str = typer.Argument(None, help="Service: enclave, bridge (default: both)"),
|
|
80
|
+
lines: int = typer.Option(50, "--lines", "-n", help="Number of recent lines to show"),
|
|
81
|
+
follow: bool = typer.Option(False, "--follow", "-f", help="Follow (tail -f) mode"),
|
|
82
|
+
):
|
|
83
|
+
"""Show or tail container logs."""
|
|
84
|
+
_docker.logs(repo_dir, service, lines=lines, follow=follow)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.command()
|
|
88
|
+
def status(
|
|
89
|
+
repo_dir: str = typer.Option(None, "--dir", "-d", help="zkai repo root"),
|
|
90
|
+
):
|
|
91
|
+
"""Show container status, wallet sync state, and enclave health."""
|
|
92
|
+
_docker.status(repo_dir)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ── On-chain registration ──────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
@app.command()
|
|
98
|
+
def register(
|
|
99
|
+
repo_dir: str = typer.Option(None, "--dir", "-d", help="zkai repo root"),
|
|
100
|
+
endpoint: str = typer.Option(None, "--endpoint", "-e", help="Public endpoint URL (e.g. http://1.2.3.4:8080)"),
|
|
101
|
+
model: str = typer.Option("qwen2.5-1.5b", "--model", "-m", help="Model name to advertise"),
|
|
102
|
+
price: int = typer.Option(100, "--price", "-p", help="Price per request in A0GI units (wei)"),
|
|
103
|
+
):
|
|
104
|
+
"""Register this provider on the 0G chain (run once after first start)."""
|
|
105
|
+
_register.register(repo_dir, endpoint=endpoint, model=model, price=price)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.command()
|
|
109
|
+
def deregister(
|
|
110
|
+
repo_dir: str = typer.Option(None, "--dir", "-d", help="zkai repo root"),
|
|
111
|
+
):
|
|
112
|
+
"""Remove this provider from the on-chain registry."""
|
|
113
|
+
_register.deregister(repo_dir)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command()
|
|
117
|
+
def info(
|
|
118
|
+
repo_dir: str = typer.Option(None, "--dir", "-d", help="zkai repo root"),
|
|
119
|
+
):
|
|
120
|
+
"""Print provider ID, TEE pubkey, and endpoint."""
|
|
121
|
+
_register.info(repo_dir)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── API key management ────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
@app.command()
|
|
127
|
+
def keys(
|
|
128
|
+
action: str = typer.Argument(..., help="add | list | remove | rotate"),
|
|
129
|
+
key_value: str = typer.Argument(None, help="Key to remove (for 'remove' action)"),
|
|
130
|
+
repo_dir: str = typer.Option(None, "--dir", "-d", help="zkai repo root"),
|
|
131
|
+
count: int = typer.Option(1, "--count", "-n", help="Number of keys to add/rotate"),
|
|
132
|
+
):
|
|
133
|
+
"""Show how API key management works (now centralized via dashboard)."""
|
|
134
|
+
_keys.run(action, key_value, repo_dir=repo_dir, count=count)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
app()
|
zkai_cli/register.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""
|
|
2
|
+
zkai register — register provider on 0G chain
|
|
3
|
+
zkai deregister — remove provider from registry
|
|
4
|
+
zkai info — print provider ID, endpoint
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
import typer
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.prompt import Prompt, Confirm
|
|
16
|
+
|
|
17
|
+
from zkai_cli.util import (
|
|
18
|
+
console, err_console,
|
|
19
|
+
compose_dir, find_repo_root,
|
|
20
|
+
require_docker, read_env_file,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_PROVIDER_ID_FILE = ".provider_id"
|
|
24
|
+
_ENCLAVE_URL = "http://127.0.0.1:8080"
|
|
25
|
+
_BRIDGE_URL = "http://127.0.0.1:7300"
|
|
26
|
+
|
|
27
|
+
import os as _os
|
|
28
|
+
_AUTH_URL = _os.environ.get("ZKAI_AUTH_URL", "").rstrip("/")
|
|
29
|
+
_RELAY_URL = _os.environ.get("ZKAI_RELAY_URL", "").rstrip("/")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── register ──────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
def register(
|
|
35
|
+
repo_dir: str | None,
|
|
36
|
+
endpoint: str | None,
|
|
37
|
+
model: str,
|
|
38
|
+
price: int,
|
|
39
|
+
):
|
|
40
|
+
require_docker()
|
|
41
|
+
repo = find_repo_root(repo_dir)
|
|
42
|
+
|
|
43
|
+
relay_url = _RELAY_URL
|
|
44
|
+
if not relay_url:
|
|
45
|
+
env = read_env_file(repo)
|
|
46
|
+
relay_url = env.get("ZKAI_RELAY_URL", "").rstrip("/")
|
|
47
|
+
auth_url = _AUTH_URL or read_env_file(repo).get("ZKAI_AUTH_URL", "").rstrip("/")
|
|
48
|
+
|
|
49
|
+
# Check bridge is up
|
|
50
|
+
_wait_for_bridge()
|
|
51
|
+
|
|
52
|
+
# Get bridge's EVM address (this IS the provider ID on 0G chain)
|
|
53
|
+
console.print("[bold]Fetching provider address from bridge...[/bold]")
|
|
54
|
+
provider_id = _get_bridge_address()
|
|
55
|
+
console.print(f" provider address (0G): {provider_id}")
|
|
56
|
+
|
|
57
|
+
# Get TEE pubkey from enclave (for info purposes)
|
|
58
|
+
pubkey = ""
|
|
59
|
+
try:
|
|
60
|
+
pubkey = _get_enclave_pubkey()
|
|
61
|
+
console.print(f" TEE pubkey: {pubkey[:16]}...{pubkey[-8:]}")
|
|
62
|
+
except Exception:
|
|
63
|
+
console.print(" [dim]Enclave not reachable — skipping pubkey check[/dim]")
|
|
64
|
+
|
|
65
|
+
# Endpoint
|
|
66
|
+
if not endpoint:
|
|
67
|
+
if relay_url:
|
|
68
|
+
endpoint = f"{relay_url}/relay/{provider_id}"
|
|
69
|
+
console.print(f" [dim]Using relay endpoint: {endpoint}[/dim]")
|
|
70
|
+
else:
|
|
71
|
+
endpoint = Prompt.ask(
|
|
72
|
+
"\nPublic endpoint URL (consumers will connect here)",
|
|
73
|
+
default="http://localhost:8080",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
console.print(f"\n[bold]Registering...[/bold]")
|
|
77
|
+
console.print(f" endpoint: {endpoint}")
|
|
78
|
+
console.print(f" model: {model}")
|
|
79
|
+
console.print(f" price: {price} A0GI/req")
|
|
80
|
+
|
|
81
|
+
# Fetch hardware info
|
|
82
|
+
hardware = None
|
|
83
|
+
try:
|
|
84
|
+
hw_resp = requests.get(f"{_ENCLAVE_URL}/health", timeout=5)
|
|
85
|
+
if hw_resp.ok:
|
|
86
|
+
hardware = hw_resp.json().get("hardware")
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
# Register in central gateway DB
|
|
91
|
+
tx_id = "pending"
|
|
92
|
+
if auth_url:
|
|
93
|
+
try:
|
|
94
|
+
r = requests.post(
|
|
95
|
+
f"{auth_url}/api/providers/register",
|
|
96
|
+
json={"provider_id": provider_id, "endpoint": endpoint, "model": model, "price": price, "hardware": hardware},
|
|
97
|
+
timeout=15,
|
|
98
|
+
)
|
|
99
|
+
if r.ok:
|
|
100
|
+
console.print(" [green]Registered in central gateway DB[/green]")
|
|
101
|
+
else:
|
|
102
|
+
console.print(f" [yellow]Warning: gateway DB registration failed: {r.text[:80]}[/yellow]")
|
|
103
|
+
except Exception as e:
|
|
104
|
+
console.print(f" [yellow]Warning: could not reach gateway ({e})[/yellow]")
|
|
105
|
+
|
|
106
|
+
# Submit on-chain tx via bridge
|
|
107
|
+
console.print(" Submitting on-chain tx...")
|
|
108
|
+
try:
|
|
109
|
+
resp = requests.post(
|
|
110
|
+
f"{_BRIDGE_URL}/registry/register-provider",
|
|
111
|
+
json={
|
|
112
|
+
"endpoint": endpoint,
|
|
113
|
+
"model": model,
|
|
114
|
+
"price": str(price),
|
|
115
|
+
},
|
|
116
|
+
timeout=120,
|
|
117
|
+
)
|
|
118
|
+
if resp.ok:
|
|
119
|
+
result = resp.json()
|
|
120
|
+
tx_id = result.get("tx_id", "submitted")
|
|
121
|
+
provider_id = result.get("provider_id", provider_id)
|
|
122
|
+
console.print(f" [green]On-chain tx: {tx_id[:20]}...[/green]")
|
|
123
|
+
else:
|
|
124
|
+
console.print(f" [yellow]On-chain tx failed (gateway registration still active): {resp.text[:80]}[/yellow]")
|
|
125
|
+
except Exception as e:
|
|
126
|
+
console.print(f" [yellow]On-chain tx error: {e}[/yellow]")
|
|
127
|
+
|
|
128
|
+
# Save provider info locally
|
|
129
|
+
pid_file = compose_dir(repo) / _PROVIDER_ID_FILE
|
|
130
|
+
pid_file.write_text(json.dumps({
|
|
131
|
+
"provider_id": provider_id,
|
|
132
|
+
"pubkey": pubkey,
|
|
133
|
+
"endpoint": endpoint,
|
|
134
|
+
"model": model,
|
|
135
|
+
"price": price,
|
|
136
|
+
}))
|
|
137
|
+
|
|
138
|
+
console.print(Panel(
|
|
139
|
+
f"[green bold]Provider registered![/green bold]\n\n"
|
|
140
|
+
f" TX: {tx_id}\n"
|
|
141
|
+
f" Provider ID: {provider_id}\n"
|
|
142
|
+
f" Endpoint: {endpoint}\n\n"
|
|
143
|
+
f"Saved to [dim]{pid_file}[/dim]\n"
|
|
144
|
+
f"Your node is now discoverable by consumers on the 0G registry.",
|
|
145
|
+
border_style="green",
|
|
146
|
+
))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ── deregister ────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
def deregister(repo_dir: str | None):
|
|
152
|
+
require_docker()
|
|
153
|
+
repo = find_repo_root(repo_dir)
|
|
154
|
+
|
|
155
|
+
pid_file = compose_dir(repo) / _PROVIDER_ID_FILE
|
|
156
|
+
provider_id = _load_provider_id(pid_file)
|
|
157
|
+
|
|
158
|
+
console.print(f"[bold]Deregistering provider:[/bold] {provider_id}")
|
|
159
|
+
if not Confirm.ask("Are you sure? This removes you from the on-chain registry.", default=False):
|
|
160
|
+
console.print("Aborted.")
|
|
161
|
+
raise typer.Exit(0)
|
|
162
|
+
|
|
163
|
+
resp = requests.post(
|
|
164
|
+
f"{_BRIDGE_URL}/registry/deregister-provider",
|
|
165
|
+
json={"provider_id": provider_id},
|
|
166
|
+
timeout=60,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if not resp.ok:
|
|
170
|
+
data = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
|
|
171
|
+
err_console.print(f"[red]Deregistration failed:[/red] {data.get('error', resp.text)}")
|
|
172
|
+
raise typer.Exit(1)
|
|
173
|
+
|
|
174
|
+
env = read_env_file(repo)
|
|
175
|
+
auth_url = _AUTH_URL or env.get("ZKAI_AUTH_URL", "").rstrip("/")
|
|
176
|
+
if auth_url:
|
|
177
|
+
try:
|
|
178
|
+
requests.post(
|
|
179
|
+
f"{auth_url}/api/providers/deregister",
|
|
180
|
+
json={"provider_id": provider_id},
|
|
181
|
+
timeout=10,
|
|
182
|
+
)
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
console.print("[green]Provider deregistered.[/green]")
|
|
187
|
+
pid_file.unlink(missing_ok=True)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ── info ──────────────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
def info(repo_dir: str | None):
|
|
193
|
+
repo = find_repo_root(repo_dir)
|
|
194
|
+
pid_file = compose_dir(repo) / _PROVIDER_ID_FILE
|
|
195
|
+
|
|
196
|
+
if not pid_file.exists():
|
|
197
|
+
console.print("[yellow]Provider not registered yet.[/yellow] Run [bold]zkai register[/bold] first.")
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
data = json.loads(pid_file.read_text())
|
|
201
|
+
console.print()
|
|
202
|
+
console.print(f" [bold]Provider ID (0G):[/bold] {data.get('provider_id', '?')}")
|
|
203
|
+
console.print(f" [bold]TEE Pubkey:[/bold] {data.get('pubkey', '?')}")
|
|
204
|
+
console.print(f" [bold]Endpoint:[/bold] {data.get('endpoint', '?')}")
|
|
205
|
+
console.print(f" [bold]Model:[/bold] {data.get('model', '?')}")
|
|
206
|
+
console.print(f" [bold]Price:[/bold] {data.get('price', '?')} A0GI/req")
|
|
207
|
+
console.print()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
def _get_bridge_address() -> str:
|
|
213
|
+
try:
|
|
214
|
+
r = requests.get(f"{_BRIDGE_URL}/health", timeout=10)
|
|
215
|
+
r.raise_for_status()
|
|
216
|
+
addr = r.json().get("address")
|
|
217
|
+
if not addr:
|
|
218
|
+
raise ValueError("No address in bridge health response")
|
|
219
|
+
return addr
|
|
220
|
+
except Exception as e:
|
|
221
|
+
err_console.print(f"[red]Cannot reach bridge at {_BRIDGE_URL}/health:[/red] {e}")
|
|
222
|
+
err_console.print("Make sure the bridge is running: [bold]zkai start[/bold]")
|
|
223
|
+
raise typer.Exit(1)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _get_enclave_pubkey() -> str:
|
|
227
|
+
r = requests.get(f"{_ENCLAVE_URL}/pubkey", timeout=10)
|
|
228
|
+
r.raise_for_status()
|
|
229
|
+
return r.json()["pubkey"]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _wait_for_bridge(timeout: int = 30):
|
|
233
|
+
console.print("[bold]Checking bridge...[/bold]", end=" ")
|
|
234
|
+
deadline = time.time() + timeout
|
|
235
|
+
while time.time() < deadline:
|
|
236
|
+
try:
|
|
237
|
+
r = requests.get(f"{_BRIDGE_URL}/health", timeout=3)
|
|
238
|
+
data = r.json()
|
|
239
|
+
if data.get("synced"):
|
|
240
|
+
console.print("[green]ready[/green]")
|
|
241
|
+
return
|
|
242
|
+
except Exception:
|
|
243
|
+
pass
|
|
244
|
+
time.sleep(2)
|
|
245
|
+
console.print(".", end="", flush=True)
|
|
246
|
+
|
|
247
|
+
err_console.print(f"\n[red]Bridge not reachable after {timeout}s.[/red]")
|
|
248
|
+
err_console.print("Run [bold]zkai logs bridge[/bold] to diagnose.")
|
|
249
|
+
raise typer.Exit(1)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _load_provider_id(pid_file: Path) -> str:
|
|
253
|
+
if not pid_file.exists():
|
|
254
|
+
err_console.print(
|
|
255
|
+
"[red]No provider_id found.[/red] "
|
|
256
|
+
"Run [bold]zkai register[/bold] first."
|
|
257
|
+
)
|
|
258
|
+
raise typer.Exit(1)
|
|
259
|
+
return json.loads(pid_file.read_text())["provider_id"]
|
zkai_cli/setup.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
zkai init — guided first-time setup wizard
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
import typer
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.prompt import Confirm, Prompt
|
|
14
|
+
|
|
15
|
+
from zkai_cli.util import (
|
|
16
|
+
console, err_console,
|
|
17
|
+
deploy_dir, compose_dir, env_file, ensure_repo,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
GATEWAY_URL = "https://zkai-ether-og.vercel.app"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ── init wizard ───────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
def run_init(repo_dir: str | None):
|
|
26
|
+
console.print(Panel.fit(
|
|
27
|
+
"[bold violet]ZKai Provider Setup[/bold violet]\n"
|
|
28
|
+
"Gets your node configured and ready to earn A0GI.",
|
|
29
|
+
border_style="violet",
|
|
30
|
+
))
|
|
31
|
+
|
|
32
|
+
repo = ensure_repo(repo_dir)
|
|
33
|
+
|
|
34
|
+
# 1. Fetch relay config from gateway
|
|
35
|
+
relay_config = _fetch_relay_config()
|
|
36
|
+
|
|
37
|
+
# 2. EVM private key
|
|
38
|
+
private_key = _step_private_key(repo)
|
|
39
|
+
|
|
40
|
+
# 3. Write all config files
|
|
41
|
+
_write_all_config(repo, relay_config, private_key)
|
|
42
|
+
|
|
43
|
+
# 4. Print next steps
|
|
44
|
+
_print_next_steps(repo, private_key)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _fetch_relay_config() -> dict:
|
|
48
|
+
console.print("[dim]Fetching relay config from gateway...[/dim]", end=" ")
|
|
49
|
+
try:
|
|
50
|
+
r = requests.get(f"{GATEWAY_URL}/api/relay-config", timeout=10)
|
|
51
|
+
r.raise_for_status()
|
|
52
|
+
data = r.json()
|
|
53
|
+
console.print("[green]ok[/green]")
|
|
54
|
+
return data
|
|
55
|
+
except Exception as e:
|
|
56
|
+
console.print("[red]failed[/red]")
|
|
57
|
+
err_console.print(f"[red]Could not reach {GATEWAY_URL}/api/relay-config: {e}[/red]")
|
|
58
|
+
err_console.print("Check your internet connection and try again.")
|
|
59
|
+
raise typer.Exit(1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _step_private_key(repo: Path) -> str:
|
|
63
|
+
console.rule("[bold]EVM Wallet (0G Chain)[/bold]")
|
|
64
|
+
ef = env_file(repo)
|
|
65
|
+
|
|
66
|
+
# Check if key already exists in .env
|
|
67
|
+
if ef.exists():
|
|
68
|
+
existing_env = {}
|
|
69
|
+
for line in ef.read_text().splitlines():
|
|
70
|
+
if "=" in line and not line.startswith("#"):
|
|
71
|
+
k, _, v = line.partition("=")
|
|
72
|
+
existing_env[k.strip()] = v.strip()
|
|
73
|
+
existing_key = existing_env.get("ZKAI_PRIVATE_KEY", "")
|
|
74
|
+
if existing_key:
|
|
75
|
+
console.print(f"[green]Private key already configured[/green] ({existing_key[:6]}...)")
|
|
76
|
+
if not Confirm.ask("Replace with a new key?", default=False):
|
|
77
|
+
return existing_key
|
|
78
|
+
|
|
79
|
+
console.print(
|
|
80
|
+
"\nYou need an EVM private key to pay gas on the 0G Galileo testnet.\n"
|
|
81
|
+
"[dim]Generate one in MetaMask: Account → Export Private Key[/dim]\n"
|
|
82
|
+
"[dim]Or use: cast wallet new (from Foundry)[/dim]\n"
|
|
83
|
+
)
|
|
84
|
+
key = Prompt.ask("Paste your EVM private key (hex, with or without 0x prefix)").strip()
|
|
85
|
+
key = key if key.startswith("0x") else "0x" + key
|
|
86
|
+
|
|
87
|
+
if not re.fullmatch(r"0x[0-9a-fA-F]{64}", key):
|
|
88
|
+
err_console.print("[red]Invalid private key — must be 64 hex chars.[/red]")
|
|
89
|
+
raise typer.Exit(1)
|
|
90
|
+
|
|
91
|
+
return key
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _write_all_config(repo: Path, relay_config: dict, private_key: str):
|
|
95
|
+
console.rule("[bold]Writing config files[/bold]")
|
|
96
|
+
|
|
97
|
+
ef = env_file(repo)
|
|
98
|
+
ef.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
env_content = (
|
|
100
|
+
f"ZKAI_PRIVATE_KEY={private_key}\n"
|
|
101
|
+
f"OG_RPC_URL={relay_config.get('og_rpc_url', 'https://evmrpc-testnet.0g.ai')}\n"
|
|
102
|
+
f"ZKAI_AUTH_URL={relay_config.get('auth_url', GATEWAY_URL)}\n"
|
|
103
|
+
f"ZKAI_RELAY_URL={relay_config.get('relay_url', 'https://zkai-relay.fly.dev')}\n"
|
|
104
|
+
f"ZKAI_RELAY_SECRET={relay_config.get('relay_secret', '')}\n"
|
|
105
|
+
f"ZKAI_PRICE_PER_REQUEST={relay_config.get('price_per_request', 100)}\n"
|
|
106
|
+
f"OLLAMA_MODEL=qwen2.5:1.5b\n"
|
|
107
|
+
f"MAX_TOKENS=512\n"
|
|
108
|
+
)
|
|
109
|
+
ef.write_text(env_content)
|
|
110
|
+
ef.chmod(0o600)
|
|
111
|
+
console.print(f" [green]✓[/green] {ef}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _print_next_steps(repo: Path, private_key: str):
|
|
115
|
+
console.print()
|
|
116
|
+
console.print(Panel(
|
|
117
|
+
"[bold yellow]Fund your wallet before starting[/bold yellow]\n\n"
|
|
118
|
+
"Your wallet pays gas for on-chain transactions on 0G Galileo testnet.\n\n"
|
|
119
|
+
"[bold]1.[/bold] Get your wallet address:\n"
|
|
120
|
+
" [cyan]zkai start[/cyan]\n"
|
|
121
|
+
" [cyan]zkai logs bridge[/cyan] (look for '0G address: 0x...')\n\n"
|
|
122
|
+
"[bold]2.[/bold] Get testnet A0GI from the faucet:\n"
|
|
123
|
+
" [link=https://faucet.0g.ai]https://faucet.0g.ai[/link]\n\n"
|
|
124
|
+
"[bold]3.[/bold] Once funded, register and go live:\n"
|
|
125
|
+
" [cyan]zkai register --model qwen2.5:1.5b --price 100[/cyan]\n\n"
|
|
126
|
+
"[bold]4.[/bold] Check status:\n"
|
|
127
|
+
" [cyan]zkai status[/cyan]",
|
|
128
|
+
border_style="yellow",
|
|
129
|
+
title="Next steps",
|
|
130
|
+
))
|
zkai_cli/util.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Shared utilities: repo detection, console, subprocess."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
err_console = Console(stderr=True)
|
|
13
|
+
|
|
14
|
+
GITHUB_REPO = "https://github.com/Eshan276/zkai.git"
|
|
15
|
+
DEFAULT_CLONE_DIR = Path.home() / "zkai"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ── Repo detection ────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
def find_repo_root(hint: str | None = None, auto_clone: bool = False) -> Path:
|
|
21
|
+
"""
|
|
22
|
+
Locate the zkai repo root. Search order:
|
|
23
|
+
1. --dir flag passed by user
|
|
24
|
+
2. Walk up from cwd looking for provider/docker-compose.yml
|
|
25
|
+
3. ~/zkai
|
|
26
|
+
4. If auto_clone=True, clone to ~/zkai automatically
|
|
27
|
+
"""
|
|
28
|
+
if hint:
|
|
29
|
+
p = Path(hint).expanduser().resolve()
|
|
30
|
+
_assert_repo(p)
|
|
31
|
+
return p
|
|
32
|
+
|
|
33
|
+
# Walk up from cwd
|
|
34
|
+
cur = Path.cwd()
|
|
35
|
+
for candidate in [cur, *cur.parents]:
|
|
36
|
+
if (candidate / "provider" / "docker-compose.yml").exists():
|
|
37
|
+
return candidate
|
|
38
|
+
|
|
39
|
+
# ~/zkai fallback
|
|
40
|
+
if DEFAULT_CLONE_DIR.exists() and (DEFAULT_CLONE_DIR / "provider" / "docker-compose.yml").exists():
|
|
41
|
+
return DEFAULT_CLONE_DIR
|
|
42
|
+
|
|
43
|
+
if auto_clone:
|
|
44
|
+
return _clone_repo()
|
|
45
|
+
|
|
46
|
+
err_console.print(
|
|
47
|
+
"[red]ZKai repo not found.[/red] "
|
|
48
|
+
"Run [bold]zkai init[/bold] to set up, or pass [bold]--dir /path/to/zkai[/bold]."
|
|
49
|
+
)
|
|
50
|
+
raise typer.Exit(1)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def ensure_repo(repo_dir: str | None) -> Path:
|
|
54
|
+
"""Like find_repo_root but always auto-clones if missing. Used by init."""
|
|
55
|
+
if repo_dir:
|
|
56
|
+
p = Path(repo_dir).expanduser().resolve()
|
|
57
|
+
_assert_repo(p)
|
|
58
|
+
return p
|
|
59
|
+
|
|
60
|
+
# Walk up from cwd
|
|
61
|
+
cur = Path.cwd()
|
|
62
|
+
for candidate in [cur, *cur.parents]:
|
|
63
|
+
if (candidate / "provider" / "docker-compose.yml").exists():
|
|
64
|
+
return candidate
|
|
65
|
+
|
|
66
|
+
# ~/zkai fallback
|
|
67
|
+
if DEFAULT_CLONE_DIR.exists() and (DEFAULT_CLONE_DIR / "provider" / "docker-compose.yml").exists():
|
|
68
|
+
return DEFAULT_CLONE_DIR
|
|
69
|
+
|
|
70
|
+
return _clone_repo()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _clone_repo() -> Path:
|
|
74
|
+
dest = DEFAULT_CLONE_DIR
|
|
75
|
+
console.print(f"[bold]Cloning ZKai repo to {dest}...[/bold]")
|
|
76
|
+
if not _check_git():
|
|
77
|
+
err_console.print("[red]git not found.[/red] Install git and retry.")
|
|
78
|
+
raise typer.Exit(1)
|
|
79
|
+
result = subprocess.run(
|
|
80
|
+
["git", "clone", "--depth=1", GITHUB_REPO, str(dest)],
|
|
81
|
+
check=False,
|
|
82
|
+
)
|
|
83
|
+
if result.returncode != 0:
|
|
84
|
+
err_console.print("[red]Failed to clone repo.[/red]")
|
|
85
|
+
raise typer.Exit(1)
|
|
86
|
+
console.print(f"[green]Cloned to {dest}[/green]")
|
|
87
|
+
return dest
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _assert_repo(p: Path):
|
|
91
|
+
if not (p / "provider" / "docker-compose.yml").exists():
|
|
92
|
+
err_console.print(f"[red]{p}[/red] does not look like a zkai repo (missing provider/docker-compose.yml).")
|
|
93
|
+
raise typer.Exit(1)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _check_git() -> bool:
|
|
97
|
+
return subprocess.run(["git", "--version"], capture_output=True).returncode == 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def compose_dir(repo: Path) -> Path:
|
|
101
|
+
return repo / "provider"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def deploy_dir(repo: Path) -> Path:
|
|
105
|
+
return repo / "deploy"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def env_file(repo: Path) -> Path:
|
|
109
|
+
return compose_dir(repo) / ".env"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def read_env_file(repo: Path) -> dict[str, str]:
|
|
113
|
+
"""Parse provider/.env into a dict. Returns empty dict if file missing."""
|
|
114
|
+
ef = env_file(repo)
|
|
115
|
+
result: dict[str, str] = {}
|
|
116
|
+
if not ef.exists():
|
|
117
|
+
return result
|
|
118
|
+
for line in ef.read_text().splitlines():
|
|
119
|
+
line = line.strip()
|
|
120
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
121
|
+
continue
|
|
122
|
+
k, _, v = line.partition("=")
|
|
123
|
+
result[k.strip()] = v.strip()
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ── Shell helpers ─────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
def run(cmd: list[str], cwd: Path | None = None, check: bool = True, capture: bool = False) -> subprocess.CompletedProcess:
|
|
130
|
+
kwargs: dict = dict(cwd=str(cwd) if cwd else None)
|
|
131
|
+
if capture:
|
|
132
|
+
kwargs["capture_output"] = True
|
|
133
|
+
kwargs["text"] = True
|
|
134
|
+
return subprocess.run(cmd, check=check, **kwargs)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def stream(cmd: list[str], cwd: Path | None = None):
|
|
138
|
+
result = subprocess.run(cmd, cwd=str(cwd) if cwd else None)
|
|
139
|
+
if result.returncode != 0:
|
|
140
|
+
raise typer.Exit(result.returncode)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def require_docker():
|
|
144
|
+
r = subprocess.run(["docker", "compose", "version"], capture_output=True)
|
|
145
|
+
if r.returncode != 0:
|
|
146
|
+
err_console.print("[red]docker compose not found.[/red] Install Docker Engine with the Compose plugin.")
|
|
147
|
+
raise typer.Exit(1)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def require_node(repo: Path):
|
|
151
|
+
r = subprocess.run(["node", "--version"], capture_output=True)
|
|
152
|
+
if r.returncode != 0:
|
|
153
|
+
err_console.print("[red]node not found.[/red] Install Node.js 20+.")
|
|
154
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zkai-cli
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: ZKai provider CLI — setup, register, and manage your verifiable AI inference node on 0G chain
|
|
5
|
+
Project-URL: Homepage, https://zkai-ether-og.vercel.app
|
|
6
|
+
Project-URL: Repository, https://github.com/skyyycodes/zkai-eth
|
|
7
|
+
Project-URL: Issues, https://github.com/skyyycodes/zkai-eth/issues
|
|
8
|
+
Author-email: ZKai <team@zkai.network>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: 0g,ai,blockchain,cli,inference,node,provider,tdx,tee,verifiable
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
23
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Requires-Dist: requests>=2.31
|
|
26
|
+
Requires-Dist: rich>=13
|
|
27
|
+
Requires-Dist: typer>=0.12
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# zkai-cli
|
|
31
|
+
|
|
32
|
+
Provider node management CLI for [ZKai](https://zkai-ether-og.vercel.app) — the verifiable AI inference marketplace on 0G chain.
|
|
33
|
+
|
|
34
|
+
This package installs the `zkai` command, which automates everything a provider needs: wallet setup, container lifecycle (Docker Compose), and on-chain registration against the ZKai contracts deployed on 0G mainnet.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install zkai-cli
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
Becoming a provider takes 4 commands:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# 1. Initial setup — prompts for an EVM private key, writes .env
|
|
48
|
+
zkai init
|
|
49
|
+
|
|
50
|
+
# 2. Start the provider (bridge + TDX-attested enclave)
|
|
51
|
+
zkai start
|
|
52
|
+
|
|
53
|
+
# 3. Verify it's running
|
|
54
|
+
zkai status
|
|
55
|
+
|
|
56
|
+
# 4. Register on-chain — your wallet becomes your provider ID
|
|
57
|
+
zkai register --model qwen2.5:1.5b --price 100
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Your node is now discoverable by ZKai consumers. Every inference you serve pays you native 0G straight to your wallet.
|
|
61
|
+
|
|
62
|
+
## Commands
|
|
63
|
+
|
|
64
|
+
| Command | Purpose |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `zkai init` | First-time setup wizard (wallet, .env, relay config). |
|
|
67
|
+
| `zkai start` | Bring up the provider containers via `docker compose`. |
|
|
68
|
+
| `zkai stop` | Stop and remove containers. |
|
|
69
|
+
| `zkai restart [service]` | Restart all or one of: `bridge`, `enclave`. |
|
|
70
|
+
| `zkai status` | Show container health, wallet sync, enclave state, dashboard URL. |
|
|
71
|
+
| `zkai logs [service]` | Tail logs from a service. |
|
|
72
|
+
| `zkai register` | Register the provider in the on-chain `ProviderRegistry`. |
|
|
73
|
+
| `zkai deregister` | Remove the provider from the on-chain registry. |
|
|
74
|
+
| `zkai info` | Print local provider metadata. |
|
|
75
|
+
| `zkai keys` | Print API key management info. |
|
|
76
|
+
|
|
77
|
+
Run `zkai --help` for the full reference.
|
|
78
|
+
|
|
79
|
+
## Requirements
|
|
80
|
+
|
|
81
|
+
- Linux machine with Docker Engine + Compose plugin (Docker Desktop on macOS works too).
|
|
82
|
+
- An EVM-compatible wallet with a small amount of native 0G for gas. Get testnet 0G from [faucet.0g.ai](https://faucet.0g.ai).
|
|
83
|
+
- A model you want to serve (`qwen2.5:1.5b` is the default; any Ollama model works).
|
|
84
|
+
|
|
85
|
+
## How it fits together
|
|
86
|
+
|
|
87
|
+
ZKai providers run two containers:
|
|
88
|
+
|
|
89
|
+
- **bridge** — Node.js sidecar that wraps `ethers.js` and talks to 0G chain on your behalf. Registers, attests, and accepts payment.
|
|
90
|
+
- **enclave** — Python/FastAPI service running inside a Gramine TDX-sealed runtime, serving an OpenAI-compatible inference endpoint.
|
|
91
|
+
|
|
92
|
+
The CLI hides all of this behind a few commands. Edit `provider/.env` if you need to override the model, gateway URL, or relay endpoint.
|
|
93
|
+
|
|
94
|
+
## Links
|
|
95
|
+
|
|
96
|
+
- Repository — https://github.com/skyyycodes/zkai-eth
|
|
97
|
+
- Dashboard — https://zkai-ether-og.vercel.app
|
|
98
|
+
- 0G chain explorer — https://chainscan.0g.ai
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
zkai_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
zkai_cli/__main__.py,sha256=zAZUVLhNWmbUyWyCXEJaoQVhcrizYzUzdwCWhWwkt08,68
|
|
3
|
+
zkai_cli/docker.py,sha256=YpeQ_erk_HAYM3V8bNy7ux2zbD7Crgu15XeXzz20pHk,9741
|
|
4
|
+
zkai_cli/keys.py,sha256=xcGEd-xOw4PRd0v3AnpWLVdvmM9N3XJrbiYx27P8Z2M,894
|
|
5
|
+
zkai_cli/main.py,sha256=LB2izVjeQpLN1CyeNUm9DWoi2K_NYghfFLmZ4gYa9j4,5225
|
|
6
|
+
zkai_cli/register.py,sha256=mdUqQpTnJy_do9wI9hk5u3Pp0c1gzBfF5rjhLzys2ss,9235
|
|
7
|
+
zkai_cli/setup.py,sha256=KPjJHYfEp50crm1eFqcpIyjpanapWgbX4TYD2Wr4NGY,4716
|
|
8
|
+
zkai_cli/util.py,sha256=hiDGC7IMt_UdD0iwmRVfzz8-bNxreHEq6ZGP0qI-WM8,4952
|
|
9
|
+
zkai_cli-0.5.0.dist-info/METADATA,sha256=EmKq4SZjvC_vPTJxnI_7uRwJ0WqpOvySeD-B7kknIY8,3866
|
|
10
|
+
zkai_cli-0.5.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
+
zkai_cli-0.5.0.dist-info/entry_points.txt,sha256=syWg-3FI522vjYjZHjcfuOpEU1w-e8so3fHCzd9LESs,43
|
|
12
|
+
zkai_cli-0.5.0.dist-info/RECORD,,
|