kiteml-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kite_cli/__init__.py +3 -0
- kite_cli/auth.py +184 -0
- kite_cli/cli/__init__.py +0 -0
- kite_cli/cli/commands/__init__.py +0 -0
- kite_cli/cli/commands/augment.py +164 -0
- kite_cli/cli/commands/auth_cmd.py +57 -0
- kite_cli/cli/commands/config_cmd.py +39 -0
- kite_cli/cli/commands/rl.py +356 -0
- kite_cli/cli/commands/twin.py +322 -0
- kite_cli/cli/main.py +88 -0
- kite_cli/client.py +180 -0
- kite_cli/config.py +80 -0
- kite_cli/mcp/__init__.py +0 -0
- kite_cli/mcp/server.py +464 -0
- kite_cli/output.py +79 -0
- kite_cli/progress.py +104 -0
- kiteml_cli-0.1.0.dist-info/METADATA +154 -0
- kiteml_cli-0.1.0.dist-info/RECORD +20 -0
- kiteml_cli-0.1.0.dist-info/WHEEL +4 -0
- kiteml_cli-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""`kite rl` — train robot policies in simulation via the public /v1/rl_runs API: a task spec in, one run per seed,
|
|
2
|
+
and a bundle out (the policy as ONNX and a checkpoint, the MuJoCo scene with a script that plays it, clips, and a
|
|
3
|
+
report with a verdict).
|
|
4
|
+
|
|
5
|
+
kite rl catalog robots, objectives with every parameter, hardware, budgets
|
|
6
|
+
kite rl plan open_duck_mini_v2 "walk forward briskly" -o walk.json English -> SPEC.json; creates nothing
|
|
7
|
+
kite rl validate walk.json free: what each reward term pays canned policies, and fixes
|
|
8
|
+
kite rl estimate walk.json minutes and tokens
|
|
9
|
+
kite rl train walk.json [--seeds 3] [--budget probe] [--wait] one run per seed (rlr_...)
|
|
10
|
+
kite rl list [--limit 20] [--after rlr_...] [--group-id rlg_...]
|
|
11
|
+
kite rl status rlr_... [--wait]
|
|
12
|
+
kite rl metrics rlr_... the training curve, every 5 iterations
|
|
13
|
+
kite rl report rlr_... the verdict, its checks and metrics
|
|
14
|
+
kite rl files rlr_... every file in the bundle (path, bytes, sha256)
|
|
15
|
+
kite rl download rlr_... [-o DIR] the bundle, unpacked to DIR/kiteml_<run_id>/
|
|
16
|
+
kite rl checkpoints rlr_... the iterations it saved
|
|
17
|
+
kite rl checkpoint rlr_... 1200 [--format onnx|pt] [-o FILE]
|
|
18
|
+
kite rl cancel rlr_... a run that trained keeps (and packages) its policy
|
|
19
|
+
kite rl fork rlr_... [--set sigma_lin=0.02 ...] [--seeds N] its weights and schedule, new reward parameters
|
|
20
|
+
kite rl continue rlr_... [--iterations N] the same, also keeping its optimizer
|
|
21
|
+
|
|
22
|
+
SPEC.json is a POST /v1/rl_runs body, {"robot", "task", "budget", ...}; `kite rl plan -o` writes one.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
import re
|
|
30
|
+
import uuid
|
|
31
|
+
import zipfile
|
|
32
|
+
from pathlib import Path, PurePosixPath
|
|
33
|
+
from typing import List, Optional
|
|
34
|
+
|
|
35
|
+
import httpx
|
|
36
|
+
import typer
|
|
37
|
+
|
|
38
|
+
from kite_cli.client import KiteAPIError, KiteClient, post_create
|
|
39
|
+
from kite_cli.output import print_error, print_json
|
|
40
|
+
from kite_cli.progress import wait_for_completion
|
|
41
|
+
|
|
42
|
+
app = typer.Typer(no_args_is_help=True, help="RL training in simulation: a task spec -> a trained, verified policy")
|
|
43
|
+
|
|
44
|
+
_TERMINAL = {"succeeded", "failed", "canceled"}
|
|
45
|
+
_POLL_S = 30.0
|
|
46
|
+
_SLOW_S = 180.0 # plan asks a model, validate rolls policies through the spec, create starts one job per seed
|
|
47
|
+
_SPEC = typer.Argument(..., help="A create body (JSON), e.g. from `kite rl plan -o`")
|
|
48
|
+
_RUN_ID = typer.Argument(..., help="Run id (rlr_...)")
|
|
49
|
+
_KEY = typer.Option(None, "--idempotency-key", help="Safe retries: same key + body -> the same runs "
|
|
50
|
+
"(default: a generated key)")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command()
|
|
54
|
+
def catalog():
|
|
55
|
+
"""Robots (with their verified command bands), objectives with every parameter's default and bounds, hardware
|
|
56
|
+
tiers and budget presets."""
|
|
57
|
+
try:
|
|
58
|
+
print_json(KiteClient().get("/v1/rl_runs/catalog"))
|
|
59
|
+
except KiteAPIError as e:
|
|
60
|
+
print_error(e.message, code=e.code, details=e.details)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command()
|
|
64
|
+
def plan(
|
|
65
|
+
robot: str = typer.Argument(..., help="Robot id, e.g. open_duck_mini_v2 (see `kite rl catalog`)"),
|
|
66
|
+
prompt: str = typer.Argument(..., help="The behavior, in plain words"),
|
|
67
|
+
out: Optional[Path] = typer.Option(None, "-o", "--out", help="Write the plan as a create body (SPEC.json)"),
|
|
68
|
+
):
|
|
69
|
+
"""A task spec from a plain-English description, with the reasoning behind it. Creates nothing."""
|
|
70
|
+
try:
|
|
71
|
+
planned = KiteClient(timeout=_SLOW_S).post("/v1/rl_runs/plan", json_data={"robot": robot, "prompt": prompt})
|
|
72
|
+
if out is None:
|
|
73
|
+
print_json(planned)
|
|
74
|
+
return
|
|
75
|
+
out.write_text(json.dumps({k: planned[k] for k in ("robot", "task", "budget")}, indent=2) + "\n")
|
|
76
|
+
print_json({"ok": True, "data": planned, "spec": str(out.resolve())})
|
|
77
|
+
except KiteAPIError as e:
|
|
78
|
+
print_error(e.message, code=e.code, details=e.details)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.command()
|
|
82
|
+
def validate(spec: Path = _SPEC):
|
|
83
|
+
"""Free, in seconds: the spec compiled and rolled with canned policies (zero action, random, a verified policy
|
|
84
|
+
for this robot), what each reward term pays them, and warnings for rewards that pay for the wrong thing, each
|
|
85
|
+
with a `patch` for the spec."""
|
|
86
|
+
body = _read(spec)
|
|
87
|
+
try:
|
|
88
|
+
print_json(KiteClient(timeout=_SLOW_S).post("/v1/rl_runs/validate", json_data=body))
|
|
89
|
+
except KiteAPIError as e:
|
|
90
|
+
print_error(e.message, code=e.code, details=e.details)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command()
|
|
94
|
+
def estimate(spec: Path = _SPEC):
|
|
95
|
+
"""Minutes and tokens per run; tokens are reserved at train and what training does not use is refunded."""
|
|
96
|
+
body = _read(spec)
|
|
97
|
+
try:
|
|
98
|
+
print_json(KiteClient().post("/v1/rl_runs/estimate", json_data=body))
|
|
99
|
+
except KiteAPIError as e:
|
|
100
|
+
print_error(e.message, code=e.code, details=e.details)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command()
|
|
104
|
+
def train(
|
|
105
|
+
spec: Path = _SPEC,
|
|
106
|
+
seeds: Optional[int] = typer.Option(None, "--seeds", help="Runs to start, one per seed (1-5)"),
|
|
107
|
+
budget: Optional[str] = typer.Option(
|
|
108
|
+
None, "--budget", help="probe (300 iterations, a first signal in minutes) or standard; replaces the "
|
|
109
|
+
"spec's budget.iterations"
|
|
110
|
+
),
|
|
111
|
+
wait: bool = typer.Option(False, "--wait", help="Poll every run until succeeded | failed | canceled"),
|
|
112
|
+
idempotency_key: Optional[str] = _KEY,
|
|
113
|
+
):
|
|
114
|
+
"""Train a policy: one queued run per seed, sharing a group_id. Tokens are reserved now and settled by the minute
|
|
115
|
+
when each run ends."""
|
|
116
|
+
body = _read(spec)
|
|
117
|
+
if seeds is not None:
|
|
118
|
+
body["seeds"] = seeds
|
|
119
|
+
if budget: # iterations would override the preset
|
|
120
|
+
body["budget"] = {**{k: v for k, v in (body.get("budget") or {}).items() if k != "iterations"}, "preset": budget}
|
|
121
|
+
_start(body, idempotency_key, wait)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@app.command("list")
|
|
125
|
+
def list_runs(
|
|
126
|
+
limit: int = typer.Option(20, "--limit", help="Max rows (1-100)"),
|
|
127
|
+
after: Optional[str] = typer.Option(None, "--after", help="Cursor: the previous page's next_cursor"),
|
|
128
|
+
group_id: Optional[str] = typer.Option(None, "--group-id", help="Only the runs one train started (rlg_...)"),
|
|
129
|
+
):
|
|
130
|
+
"""List your runs (newest first, last status seen; `status` has live progress)."""
|
|
131
|
+
params = {k: v for k, v in (("limit", limit), ("after", after), ("group_id", group_id)) if v}
|
|
132
|
+
try:
|
|
133
|
+
print_json(KiteClient().get("/v1/rl_runs", params=params))
|
|
134
|
+
except KiteAPIError as e:
|
|
135
|
+
print_error(e.message, code=e.code, details=e.details)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@app.command()
|
|
139
|
+
def status(
|
|
140
|
+
run_id: str = _RUN_ID,
|
|
141
|
+
wait: bool = typer.Option(False, "--wait", help="Poll until succeeded | failed | canceled"),
|
|
142
|
+
):
|
|
143
|
+
"""A run's live status, phase, progress and metrics; once packaged, its verdict and `output`."""
|
|
144
|
+
try:
|
|
145
|
+
client = KiteClient()
|
|
146
|
+
path = f"/v1/rl_runs/{run_id}"
|
|
147
|
+
if wait:
|
|
148
|
+
print_json(wait_for_completion(client, path, terminal_states=_TERMINAL, poll_interval=_POLL_S))
|
|
149
|
+
else:
|
|
150
|
+
print_json(client.get(path))
|
|
151
|
+
except KiteAPIError as e:
|
|
152
|
+
print_error(e.message, code=e.code, details=e.details)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@app.command()
|
|
156
|
+
def metrics(run_id: str = _RUN_ID):
|
|
157
|
+
"""The training curve, every 5 iterations: mean reward per step, the same by reward term, episode length and
|
|
158
|
+
throughput."""
|
|
159
|
+
try:
|
|
160
|
+
print_json(KiteClient().get(f"/v1/rl_runs/{run_id}/metrics"))
|
|
161
|
+
except KiteAPIError as e:
|
|
162
|
+
print_error(e.message, code=e.code, details=e.details)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@app.command()
|
|
166
|
+
def report(run_id: str = _RUN_ID):
|
|
167
|
+
"""The verdict and how it was reached: each check with its value and limit, the metrics, the judge's read of the
|
|
168
|
+
clip, and the checkpoint chosen. run_not_ready until the run is packaged."""
|
|
169
|
+
try:
|
|
170
|
+
print_json(KiteClient().get(f"/v1/rl_runs/{run_id}/report"))
|
|
171
|
+
except KiteAPIError as e:
|
|
172
|
+
print_error(e.message, code=e.code, details=e.details)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@app.command()
|
|
176
|
+
def files(run_id: str = _RUN_ID):
|
|
177
|
+
"""Every file in a packaged run's bundle (path, bytes, sha256), sorted by path, following the cursor."""
|
|
178
|
+
path, params = f"/v1/rl_runs/{run_id}/files", {"limit": 100}
|
|
179
|
+
try:
|
|
180
|
+
client = KiteClient()
|
|
181
|
+
data: list = []
|
|
182
|
+
while True:
|
|
183
|
+
page = client.get(path, params=params)
|
|
184
|
+
data.extend(page.get("data") or [])
|
|
185
|
+
if not page.get("has_more") or not page.get("next_cursor"):
|
|
186
|
+
break
|
|
187
|
+
params = {"limit": 100, "after": page["next_cursor"]}
|
|
188
|
+
print_json({"object": "list", "data": data, "has_more": False, "next_cursor": None})
|
|
189
|
+
except KiteAPIError as e:
|
|
190
|
+
print_error(e.message, code=e.code, details=e.details)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@app.command()
|
|
194
|
+
def download(
|
|
195
|
+
run_id: str = _RUN_ID,
|
|
196
|
+
out: Path = typer.Option(Path("."), "-o", "--out", help="Directory; the bundle unpacks to <out>/kiteml_<run_id>/"),
|
|
197
|
+
):
|
|
198
|
+
"""Download a packaged run's bundle and unpack it: <out>/kiteml_<run_id>/ with media/, policy/, mujoco/ and
|
|
199
|
+
training/."""
|
|
200
|
+
try:
|
|
201
|
+
print_json({"id": run_id, "path": str(_unzip(KiteClient(timeout=120), run_id, out.expanduser().resolve()))})
|
|
202
|
+
except KiteAPIError as e:
|
|
203
|
+
print_error(e.message, code=e.code, details=e.details)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@app.command()
|
|
207
|
+
def checkpoints(run_id: str = _RUN_ID):
|
|
208
|
+
"""The iterations the run saved (about every tenth of its budget, and the last)."""
|
|
209
|
+
try:
|
|
210
|
+
print_json(KiteClient().get(f"/v1/rl_runs/{run_id}/checkpoints"))
|
|
211
|
+
except KiteAPIError as e:
|
|
212
|
+
print_error(e.message, code=e.code, details=e.details)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@app.command()
|
|
216
|
+
def checkpoint(
|
|
217
|
+
run_id: str = _RUN_ID,
|
|
218
|
+
iteration: int = typer.Argument(..., help="A saved iteration (see `kite rl checkpoints`)"),
|
|
219
|
+
fmt: str = typer.Option("onnx", "--format", help="onnx (the deployable policy: raw observation in, action out) "
|
|
220
|
+
"or pt (the training checkpoint)"),
|
|
221
|
+
out: Optional[Path] = typer.Option(None, "-o", "--out", help="File to write (default ./<run_id>_<iteration>.<format>)"),
|
|
222
|
+
):
|
|
223
|
+
"""Download one saved iteration of a run's policy."""
|
|
224
|
+
dest = (out or Path(f"{run_id}_{iteration}.{fmt}")).expanduser().resolve()
|
|
225
|
+
try:
|
|
226
|
+
KiteClient(timeout=120).download(f"/v1/rl_runs/{run_id}/checkpoints/{iteration}/download?format={fmt}", str(dest))
|
|
227
|
+
print_json({"id": run_id, "iteration": iteration, "format": fmt, "path": str(dest)})
|
|
228
|
+
except KiteAPIError as e:
|
|
229
|
+
print_error(e.message, code=e.code, details=e.details)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@app.command()
|
|
233
|
+
def cancel(run_id: str = _RUN_ID):
|
|
234
|
+
"""Stop a run. One that has started training keeps its policy so far: it reads canceling while that is packaged,
|
|
235
|
+
then canceled with its outputs, charged for the minutes it trained. One that never started, or left nothing to
|
|
236
|
+
package, is refunded in full."""
|
|
237
|
+
try:
|
|
238
|
+
print_json(KiteClient().post(f"/v1/rl_runs/{run_id}/cancel"))
|
|
239
|
+
except KiteAPIError as e:
|
|
240
|
+
print_error(e.message, code=e.code, details=e.details)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@app.command()
|
|
244
|
+
def fork(
|
|
245
|
+
run_id: str = _RUN_ID,
|
|
246
|
+
overrides: List[str] = typer.Option([], "--set", help="key=value: a reward or randomization parameter "
|
|
247
|
+
"(repeatable; `kite rl catalog` lists them)"),
|
|
248
|
+
seeds: Optional[int] = typer.Option(None, "--seeds", help="Runs to start, one per seed (1-5)"),
|
|
249
|
+
idempotency_key: Optional[str] = _KEY,
|
|
250
|
+
):
|
|
251
|
+
"""Train from a run's weights: its robot, task and budget, with the parameters you --set."""
|
|
252
|
+
params = _params(overrides)
|
|
253
|
+
try:
|
|
254
|
+
body = from_run(KiteClient().get(f"/v1/rl_runs/{run_id}"), "fork", params=params)
|
|
255
|
+
except KiteAPIError as e:
|
|
256
|
+
print_error(e.message, code=e.code, details=e.details)
|
|
257
|
+
return
|
|
258
|
+
if seeds is not None:
|
|
259
|
+
body["seeds"] = seeds
|
|
260
|
+
_start(body, idempotency_key)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@app.command("continue")
|
|
264
|
+
def continue_run(
|
|
265
|
+
run_id: str = _RUN_ID,
|
|
266
|
+
iterations: Optional[int] = typer.Option(None, "--iterations", help="Iterations to train this time "
|
|
267
|
+
"(default: the run's own budget)"),
|
|
268
|
+
idempotency_key: Optional[str] = _KEY,
|
|
269
|
+
):
|
|
270
|
+
"""Keep training a run: its weights, schedule and optimizer, with its robot, task and budget."""
|
|
271
|
+
try:
|
|
272
|
+
body = from_run(KiteClient().get(f"/v1/rl_runs/{run_id}"), "continue", iterations=iterations)
|
|
273
|
+
except KiteAPIError as e:
|
|
274
|
+
print_error(e.message, code=e.code, details=e.details)
|
|
275
|
+
return
|
|
276
|
+
_start(body, idempotency_key)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def from_run(run: dict, mode: str, params: Optional[dict] = None, iterations: Optional[int] = None) -> dict:
|
|
280
|
+
"""A create body that starts from `run` (GET /v1/rl_runs/{id}): its robot, task, budget and description (what
|
|
281
|
+
the report's judge compares the clip against), with `params` overriding task.params and `iterations` the
|
|
282
|
+
budget's. Both resume its training schedule from its weights; `continue` also keeps its optimizer. Shared
|
|
283
|
+
with the MCP tool kite_rl_fork."""
|
|
284
|
+
task = {**run["task"], "params": {**(run["task"].get("params") or {}), **(params or {})}}
|
|
285
|
+
budget = {**(run.get("budget") or {}), **({"iterations": iterations} if iterations else {})}
|
|
286
|
+
body = {"robot": run["robot"], "task": task, "budget": budget, "from": {"run": run["id"], "mode": mode}}
|
|
287
|
+
if run.get("description"):
|
|
288
|
+
body["description"] = run["description"]
|
|
289
|
+
return body
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _start(body: dict, key: Optional[str], wait: bool = False) -> None:
|
|
293
|
+
"""POST /v1/rl_runs and print the runs it started (each polled until terminal with `wait`)."""
|
|
294
|
+
key = key or str(uuid.uuid4())
|
|
295
|
+
try:
|
|
296
|
+
client = KiteClient(timeout=_SLOW_S)
|
|
297
|
+
try:
|
|
298
|
+
page = post_create(client, "/v1/rl_runs", body, key)
|
|
299
|
+
except (httpx.TimeoutException, httpx.NetworkError) as e:
|
|
300
|
+
print_error(f"network error ({e}); re-run with --idempotency-key {key} to retry without training twice",
|
|
301
|
+
code="network_error", details={"idempotency_key": key})
|
|
302
|
+
return
|
|
303
|
+
if wait:
|
|
304
|
+
page["data"] = [wait_for_completion(client, f"/v1/rl_runs/{r['id']}", terminal_states=_TERMINAL,
|
|
305
|
+
poll_interval=_POLL_S) for r in page.get("data") or []]
|
|
306
|
+
print_json(page)
|
|
307
|
+
except KiteAPIError as e:
|
|
308
|
+
print_error(e.message, code=e.code, details=e.details)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _read(spec: Path) -> dict:
|
|
312
|
+
"""SPEC.json: a create body."""
|
|
313
|
+
try:
|
|
314
|
+
return json.loads(spec.read_text())
|
|
315
|
+
except (OSError, ValueError) as e:
|
|
316
|
+
print_error(f"could not read {spec}: {e}", code="invalid_spec")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _params(overrides: List[str]) -> dict:
|
|
320
|
+
"""--set key=value, repeated -> task.params (every parameter is a number)."""
|
|
321
|
+
out = {}
|
|
322
|
+
for kv in overrides:
|
|
323
|
+
key, _, value = kv.partition("=")
|
|
324
|
+
try:
|
|
325
|
+
number = float(value)
|
|
326
|
+
except ValueError:
|
|
327
|
+
number = None
|
|
328
|
+
if not key or number is None:
|
|
329
|
+
raise typer.BadParameter(f"--set expects key=number, got '{kv}'")
|
|
330
|
+
out[key] = number
|
|
331
|
+
return out
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _unzip(client: KiteClient, run_id: str, out: Path) -> Path:
|
|
335
|
+
"""Fetch the run's archive (one zip, unpacking to kiteml_<run_id>/), check it against the run's sha256 and
|
|
336
|
+
unpack it into `out`; keeps no zip. A mismatch, or a file outside kiteml_<run_id>/, refuses the whole archive."""
|
|
337
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]+", run_id):
|
|
338
|
+
raise KiteAPIError(400, f"unexpected run id {run_id!r}", code="parameter_invalid")
|
|
339
|
+
expected = str((client.get(f"/v1/rl_runs/{run_id}").get("output") or {}).get("sha256") or "").lower()
|
|
340
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
341
|
+
part = out / f"{run_id}.zip.part"
|
|
342
|
+
try:
|
|
343
|
+
client.download(f"/v1/rl_runs/{run_id}/archive", str(part))
|
|
344
|
+
if expected and hashlib.sha256(part.read_bytes()).hexdigest() != expected:
|
|
345
|
+
raise KiteAPIError(502, f"{run_id}.zip does not match its sha256; download it again",
|
|
346
|
+
code="archive_invalid")
|
|
347
|
+
with zipfile.ZipFile(part) as z:
|
|
348
|
+
top = {PurePosixPath(n).parts[0] for n in z.namelist()}
|
|
349
|
+
if top not in ({f"kiteml_{run_id}"}, {run_id}): # <run_id>/: runs packaged before the kiteml_ name
|
|
350
|
+
raise KiteAPIError(502, f"{run_id}.zip holds files outside kiteml_{run_id}/", code="archive_invalid")
|
|
351
|
+
z.extractall(out)
|
|
352
|
+
except zipfile.BadZipFile as e:
|
|
353
|
+
raise KiteAPIError(502, f"{run_id}.zip: {e}", code="archive_invalid") from e
|
|
354
|
+
finally:
|
|
355
|
+
part.unlink(missing_ok=True)
|
|
356
|
+
return out / top.pop()
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""`kite twin` — digital twins via the public /v1/twins API: one robot episode in, an interactable
|
|
2
|
+
MuJoCo scene out (about 100 minutes per twin).
|
|
3
|
+
|
|
4
|
+
kite twin validate lerobot/svla_so101_pickplace [--camera side] free: cameras, episodes, time estimate
|
|
5
|
+
kite twin create lerobot/svla_so101_pickplace --wait --out ./twins -> ./twins/twin_.../scene.xml
|
|
6
|
+
kite twin status twin_01K... [--wait]
|
|
7
|
+
kite twin list [--limit 20] [--after twin_...]
|
|
8
|
+
kite twin files twin_01K... [--limit 20] [--after PATH] [--all] what the archive holds, once succeeded
|
|
9
|
+
kite twin cancel twin_01K... -> canceling, then canceled once the build has stopped
|
|
10
|
+
kite twin resume twin_01K...
|
|
11
|
+
kite twin download twin_01K... [--out DIR] [--no-extract]
|
|
12
|
+
|
|
13
|
+
DATASET is a Hugging Face repo id; the API takes it as {"source": {"type": "huggingface", "repo_id": DATASET}}.
|
|
14
|
+
Where twins go: --out DIR, else $KITE_TWINS_DIR, else ./twins (`~` works). Paths printed are absolute.
|
|
15
|
+
|
|
16
|
+
A signed storage URL is fetched with a plain HTTP client, never with the Kite key; the API's own archive
|
|
17
|
+
route (download.requires_auth) goes through KiteClient. Every archive is sha256-verified before it is kept,
|
|
18
|
+
and unpacked only inside <out>/<twin_id>/.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import tarfile
|
|
27
|
+
import uuid
|
|
28
|
+
from pathlib import Path, PurePosixPath
|
|
29
|
+
from typing import Optional
|
|
30
|
+
|
|
31
|
+
import httpx
|
|
32
|
+
import typer
|
|
33
|
+
|
|
34
|
+
from kite_cli.client import KiteAPIError, KiteClient, post_create
|
|
35
|
+
from kite_cli.output import print_error, print_json
|
|
36
|
+
from kite_cli.progress import wait_for_completion
|
|
37
|
+
|
|
38
|
+
app = typer.Typer(no_args_is_help=True, help="Digital twins: one robot episode -> an interactable MuJoCo scene")
|
|
39
|
+
|
|
40
|
+
_TERMINAL = {"succeeded", "failed", "canceled"} # not canceling: the cancel was accepted, the build has not stopped
|
|
41
|
+
_POLL_S = 30.0
|
|
42
|
+
_SLOW_S = 180.0 # validate/create check the dataset server-side (the API allows the builder 120 s)
|
|
43
|
+
_DATA_FILTER = hasattr(tarfile, "data_filter") # Python >= 3.10.12 / 3.11.4 / 3.12
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.command()
|
|
47
|
+
def validate(
|
|
48
|
+
dataset: str = typer.Argument(..., help="Hugging Face dataset id or URL, e.g. lerobot/svla_so101_pickplace"),
|
|
49
|
+
camera: Optional[str] = typer.Option(None, "--camera", help="Substring of the camera that seeds the room"),
|
|
50
|
+
wrist_camera: Optional[str] = typer.Option(None, "--wrist-camera", help="Substring of the wrist camera"),
|
|
51
|
+
):
|
|
52
|
+
"""Free dry run of a create: cameras, episodes, fps, and a time estimate. Creates nothing."""
|
|
53
|
+
try:
|
|
54
|
+
client = KiteClient(timeout=_SLOW_S)
|
|
55
|
+
body = _body(source=_source(dataset), camera=camera, wrist_camera=wrist_camera)
|
|
56
|
+
print_json(client.post("/v1/twins/validate", json_data=body))
|
|
57
|
+
except KiteAPIError as e:
|
|
58
|
+
print_error(e.message, code=e.code, details=e.details)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command()
|
|
62
|
+
def create(
|
|
63
|
+
dataset: str = typer.Argument(..., help="Hugging Face dataset id or URL, e.g. lerobot/svla_so101_pickplace"),
|
|
64
|
+
name: Optional[str] = typer.Option(None, "--name", help="A short name for the twin"),
|
|
65
|
+
task: Optional[str] = typer.Option(None, "--task", help="Which objects to author: the ones the robot manipulates"),
|
|
66
|
+
prompt: Optional[str] = typer.Option(None, "--prompt", help="How to describe the room to the reconstructor"),
|
|
67
|
+
camera: Optional[str] = typer.Option(None, "--camera", help="Substring of the camera that seeds the room"),
|
|
68
|
+
wrist_camera: Optional[str] = typer.Option(None, "--wrist-camera", help="Substring of the wrist camera"),
|
|
69
|
+
ceiling_m: Optional[float] = typer.Option(
|
|
70
|
+
None, "--ceiling-m", help="Floor-to-ceiling height in m, the scale anchor (default 2.90)"
|
|
71
|
+
),
|
|
72
|
+
idempotency_key: Optional[str] = typer.Option(
|
|
73
|
+
None, "--idempotency-key", help="Safe retries: same key + body -> same twin (default: a generated key)"
|
|
74
|
+
),
|
|
75
|
+
wait: bool = typer.Option(False, "--wait", help="Poll until succeeded | failed | canceled (about 100 min)"),
|
|
76
|
+
out: Optional[Path] = typer.Option(
|
|
77
|
+
None, "-o", "--out", help="When it succeeds, download, verify and unpack the scene into DIR (implies --wait); "
|
|
78
|
+
"default location for `kite twin download` is $KITE_TWINS_DIR, else ./twins"
|
|
79
|
+
),
|
|
80
|
+
):
|
|
81
|
+
"""Build a twin from one episode of a LeRobot dataset (queued; about 100 minutes)."""
|
|
82
|
+
key = idempotency_key or str(uuid.uuid4())
|
|
83
|
+
body = _body(source=_source(dataset), name=name, task=task, prompt=prompt, camera=camera,
|
|
84
|
+
wrist_camera=wrist_camera, ceiling_m=ceiling_m)
|
|
85
|
+
try:
|
|
86
|
+
client = KiteClient(timeout=_SLOW_S)
|
|
87
|
+
try:
|
|
88
|
+
twin = post_create(client, "/v1/twins", body, key)
|
|
89
|
+
except (httpx.TimeoutException, httpx.NetworkError) as e:
|
|
90
|
+
print_error(f"network error ({e}); re-run with --idempotency-key {key} to retry without building twice",
|
|
91
|
+
code="network_error", details={"idempotency_key": key})
|
|
92
|
+
return
|
|
93
|
+
if (wait or out) and twin.get("id"):
|
|
94
|
+
twin = wait_for_completion(
|
|
95
|
+
client, f"/v1/twins/{twin['id']}", terminal_states=_TERMINAL, poll_interval=_POLL_S
|
|
96
|
+
)
|
|
97
|
+
if out is None:
|
|
98
|
+
print_json(twin)
|
|
99
|
+
elif twin.get("status") != "succeeded":
|
|
100
|
+
print_json(twin, success=False)
|
|
101
|
+
raise typer.Exit(1)
|
|
102
|
+
else:
|
|
103
|
+
saved = _save(client, twin, _out_dir(out), extract=True)
|
|
104
|
+
print_json({"ok": True, "data": twin, "scene": saved["scene"], "archive": saved["archive"]})
|
|
105
|
+
except KiteAPIError as e:
|
|
106
|
+
print_error(e.message, code=e.code, details=e.details)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@app.command()
|
|
110
|
+
def status(
|
|
111
|
+
twin_id: str = typer.Argument(..., help="Twin id (twin_...)"),
|
|
112
|
+
wait: bool = typer.Option(False, "--wait", help="Poll until succeeded | failed | canceled"),
|
|
113
|
+
):
|
|
114
|
+
"""A twin's live status, progress, stage and stage measurements; `download` once succeeded."""
|
|
115
|
+
try:
|
|
116
|
+
client = KiteClient()
|
|
117
|
+
path = f"/v1/twins/{twin_id}"
|
|
118
|
+
if wait:
|
|
119
|
+
print_json(wait_for_completion(client, path, terminal_states=_TERMINAL, poll_interval=_POLL_S))
|
|
120
|
+
else:
|
|
121
|
+
print_json(client.get(path))
|
|
122
|
+
except KiteAPIError as e:
|
|
123
|
+
print_error(e.message, code=e.code, details=e.details)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@app.command("list")
|
|
127
|
+
def list_twins(
|
|
128
|
+
limit: int = typer.Option(20, "--limit", help="Max rows (1-100)"),
|
|
129
|
+
after: Optional[str] = typer.Option(None, "--after", help="Cursor: the previous page's next_cursor"),
|
|
130
|
+
):
|
|
131
|
+
"""List your twins (newest first, last status seen; `status` has live progress)."""
|
|
132
|
+
params: dict = {"limit": limit}
|
|
133
|
+
if after:
|
|
134
|
+
params["after"] = after
|
|
135
|
+
try:
|
|
136
|
+
print_json(KiteClient().get("/v1/twins", params=params))
|
|
137
|
+
except KiteAPIError as e:
|
|
138
|
+
print_error(e.message, code=e.code, details=e.details)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@app.command("files")
|
|
142
|
+
def list_files(
|
|
143
|
+
twin_id: str = typer.Argument(..., help="Twin id (twin_...)"),
|
|
144
|
+
limit: Optional[int] = typer.Option(None, "--limit", help="Files per page, 1-100 (default 20; 100 with --all)"),
|
|
145
|
+
after: Optional[str] = typer.Option(None, "--after", help="Cursor: the previous page's next_cursor (a file path)"),
|
|
146
|
+
all_pages: bool = typer.Option(False, "--all", help="Follow next_cursor through every page; print one list"),
|
|
147
|
+
):
|
|
148
|
+
"""The files in a succeeded twin's archive (path, bytes, sha256), sorted by path; twin_not_ready before that."""
|
|
149
|
+
path = f"/v1/twins/{twin_id}/files"
|
|
150
|
+
size = limit if limit is not None else (100 if all_pages else 20)
|
|
151
|
+
try:
|
|
152
|
+
client = KiteClient()
|
|
153
|
+
if not all_pages:
|
|
154
|
+
print_json(client.get(path, params=_page(size, after)))
|
|
155
|
+
return
|
|
156
|
+
data: list = []
|
|
157
|
+
while True:
|
|
158
|
+
page = client.get(path, params=_page(size, after))
|
|
159
|
+
items = page.get("data") or []
|
|
160
|
+
data.extend(items)
|
|
161
|
+
after = page.get("next_cursor") or (items[-1].get("path") if items else None)
|
|
162
|
+
if not page.get("has_more") or not after:
|
|
163
|
+
break
|
|
164
|
+
print_json({"object": "list", "data": data, "has_more": False, "next_cursor": None})
|
|
165
|
+
except KiteAPIError as e:
|
|
166
|
+
print_error(e.message, code=e.code, details=e.details)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@app.command()
|
|
170
|
+
def cancel(twin_id: str = typer.Argument(..., help="Twin id (twin_...)")):
|
|
171
|
+
"""Stop a queued or processing twin: it reads canceling until the build has stopped, then canceled. The
|
|
172
|
+
stages it finished stay cached for a resume."""
|
|
173
|
+
try:
|
|
174
|
+
print_json(KiteClient().post(f"/v1/twins/{twin_id}/cancel"))
|
|
175
|
+
except KiteAPIError as e:
|
|
176
|
+
print_error(e.message, code=e.code, details=e.details)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@app.command()
|
|
180
|
+
def resume(twin_id: str = typer.Argument(..., help="Twin id (twin_...)")):
|
|
181
|
+
"""Re-queue a failed or canceled twin; only the stages that did not finish run again."""
|
|
182
|
+
try:
|
|
183
|
+
print_json(KiteClient().post(f"/v1/twins/{twin_id}/resume"))
|
|
184
|
+
except KiteAPIError as e:
|
|
185
|
+
print_error(e.message, code=e.code, details=e.details)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@app.command()
|
|
189
|
+
def download(
|
|
190
|
+
twin_id: str = typer.Argument(..., help="Twin id (twin_...)"),
|
|
191
|
+
out: Optional[Path] = typer.Option(None, "-o", "--out", help="Output directory (default: $KITE_TWINS_DIR, else ./twins)"),
|
|
192
|
+
extract: bool = typer.Option(
|
|
193
|
+
True, "--extract/--no-extract", help="Unpack into <out>/<twin_id>/, or keep only the .tar.gz"
|
|
194
|
+
),
|
|
195
|
+
):
|
|
196
|
+
"""Download a succeeded twin: <out>/<twin_id>.tar.gz, sha256-verified, unpacked to <out>/<twin_id>/scene.xml."""
|
|
197
|
+
try:
|
|
198
|
+
client = KiteClient(timeout=120)
|
|
199
|
+
print_json(_save(client, client.get(f"/v1/twins/{twin_id}"), _out_dir(out), extract=extract))
|
|
200
|
+
except KiteAPIError as e:
|
|
201
|
+
print_error(e.message, code=e.code, details=e.details)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _body(**fields) -> dict:
|
|
205
|
+
return {k: v for k, v in fields.items() if v not in (None, "")}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _source(dataset: str) -> dict:
|
|
209
|
+
"""The positional DATASET as the API takes it: the `source` object /v1/augmentations takes too."""
|
|
210
|
+
return {"type": "huggingface", "repo_id": dataset}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _page(limit: int, after: Optional[str]) -> dict:
|
|
214
|
+
return {"limit": limit, "after": after} if after else {"limit": limit}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _out_dir(out: Optional[Path]) -> Path:
|
|
218
|
+
"""--out, else $KITE_TWINS_DIR, else ./twins; absolute, so what is printed says exactly where the twin is."""
|
|
219
|
+
return Path(out or os.environ.get("KITE_TWINS_DIR") or "./twins").expanduser().resolve()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _save(client: KiteClient, twin: dict, out: Path, *, extract: bool) -> dict:
|
|
223
|
+
"""Fetch a succeeded twin's archive to <out>/<twin_id>.tar.gz, verify its sha256, and (extract) unpack it
|
|
224
|
+
into <out>/<twin_id>/. An archive already there that still verifies is reused."""
|
|
225
|
+
tid, d = str(twin.get("id") or ""), twin.get("download")
|
|
226
|
+
if twin.get("status") != "succeeded" or not d:
|
|
227
|
+
raise KiteAPIError(409, f"Twin '{tid}' is {twin.get('status')}, not succeeded.", code="twin_not_ready")
|
|
228
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]+", tid):
|
|
229
|
+
raise KiteAPIError(502, f"unexpected twin id {tid!r}", code="archive_invalid")
|
|
230
|
+
expected = str(d.get("sha256") or "").lower()
|
|
231
|
+
if not expected:
|
|
232
|
+
raise KiteAPIError(502, f"Twin '{tid}' has no download.sha256 to verify against.", code="checksum_missing")
|
|
233
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
234
|
+
archive = out / f"{tid}.tar.gz"
|
|
235
|
+
if not (archive.is_file() and _sha256(archive) == expected):
|
|
236
|
+
part = out / f"{tid}.tar.gz.part"
|
|
237
|
+
try:
|
|
238
|
+
_fetch(client, d, part)
|
|
239
|
+
got = _sha256(part)
|
|
240
|
+
if got != expected:
|
|
241
|
+
raise KiteAPIError(502, f"{archive.name}: sha256 {got} != expected {expected}; the file was discarded, "
|
|
242
|
+
f"retry the download", code="checksum_mismatch",
|
|
243
|
+
details={"expected": expected, "actual": got, "bytes": part.stat().st_size})
|
|
244
|
+
part.replace(archive)
|
|
245
|
+
finally:
|
|
246
|
+
part.unlink(missing_ok=True)
|
|
247
|
+
saved = {"id": tid, "archive": str(archive), "bytes": archive.stat().st_size, "sha256": expected}
|
|
248
|
+
if extract:
|
|
249
|
+
scene = _relpath(d.get("scene") or f"{tid}/scene.xml")
|
|
250
|
+
_unpack(archive, out, top=tid)
|
|
251
|
+
if not (out / scene).is_file():
|
|
252
|
+
raise KiteAPIError(502, f"{archive.name} has no {scene.as_posix()}", code="archive_invalid")
|
|
253
|
+
saved["scene"] = str(out / scene)
|
|
254
|
+
return saved
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _fetch(client: KiteClient, d: dict, dest: Path) -> None:
|
|
258
|
+
"""Stream the archive to `dest`. The Kite key goes to the Kite API only: the archive route through
|
|
259
|
+
KiteClient, a signed storage URL through a plain client that has no key to send."""
|
|
260
|
+
url = str(d.get("url") or "")
|
|
261
|
+
if not url:
|
|
262
|
+
raise KiteAPIError(502, "the twin has no download.url", code="download_failed")
|
|
263
|
+
u = httpx.URL(url)
|
|
264
|
+
if u.is_relative_url or _origin(u) == _origin(httpx.URL(client.base_url)):
|
|
265
|
+
client.download(url, str(dest))
|
|
266
|
+
return
|
|
267
|
+
if d.get("requires_auth"):
|
|
268
|
+
raise KiteAPIError(502, f"download.url is on {u.host}, not the Kite API; refusing to send it the Kite key",
|
|
269
|
+
code="download_failed")
|
|
270
|
+
with _storage_client() as http, http.stream("GET", url, headers={"Accept-Encoding": "identity"}) as r:
|
|
271
|
+
if not r.is_success:
|
|
272
|
+
r.read()
|
|
273
|
+
raise KiteAPIError(r.status_code, f"GET {u.host} answered HTTP {r.status_code}: {r.text[:200]}",
|
|
274
|
+
code="download_failed")
|
|
275
|
+
with open(dest, "wb") as f:
|
|
276
|
+
for chunk in r.iter_bytes(1 << 20):
|
|
277
|
+
f.write(chunk)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _storage_client() -> httpx.Client:
|
|
281
|
+
"""A plain client for signed storage URLs: it has no Kite credentials to send."""
|
|
282
|
+
return httpx.Client(timeout=httpx.Timeout(120.0, connect=30.0), follow_redirects=True)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _origin(u: httpx.URL) -> tuple:
|
|
286
|
+
return u.scheme, u.host, u.port or {"https": 443, "http": 80}.get(u.scheme)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _sha256(path: Path) -> str:
|
|
290
|
+
h = hashlib.sha256()
|
|
291
|
+
with open(path, "rb") as f:
|
|
292
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
293
|
+
h.update(chunk)
|
|
294
|
+
return h.hexdigest()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _relpath(p: str) -> Path:
|
|
298
|
+
rel = PurePosixPath(p)
|
|
299
|
+
if rel.is_absolute() or ".." in rel.parts or not rel.parts:
|
|
300
|
+
raise KiteAPIError(502, f"unsafe path in download.scene: {p!r}", code="archive_invalid")
|
|
301
|
+
return Path(*rel.parts)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _unpack(archive: Path, dest: Path, *, top: str) -> None:
|
|
305
|
+
"""Extract into `dest`. Every member must sit under `<top>/` (the twin id), with no absolute path or `..`.
|
|
306
|
+
Python's tarfile "data" filter, where present, also refuses links out of `dest` and device files; without
|
|
307
|
+
it (Python < 3.10.12), links and special files are refused outright."""
|
|
308
|
+
with tarfile.open(archive, "r:gz") as tar:
|
|
309
|
+
members = tar.getmembers()
|
|
310
|
+
for m in members:
|
|
311
|
+
parts = PurePosixPath(m.name).parts
|
|
312
|
+
if (parts and parts[0] != top) or ".." in parts or not (_DATA_FILTER or m.isfile() or m.isdir()):
|
|
313
|
+
raise KiteAPIError(502, f"{archive.name}: refusing to extract {m.name!r}", code="archive_invalid")
|
|
314
|
+
try:
|
|
315
|
+
if _DATA_FILTER:
|
|
316
|
+
tar.extractall(dest, members=members, filter="data")
|
|
317
|
+
else:
|
|
318
|
+
for m in members:
|
|
319
|
+
m.mode = 0o755 if m.isdir() else (m.mode | 0o600) & 0o755
|
|
320
|
+
tar.extractall(dest, members=members)
|
|
321
|
+
except tarfile.TarError as e: # includes the data filter's refusals
|
|
322
|
+
raise KiteAPIError(502, f"{archive.name}: {e}", code="archive_invalid") from e
|