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 ADDED
File without changes
zkai_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from zkai_cli.main import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ zkai = zkai_cli.main:app