agi-diy 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agi_diy-0.1.0/.gitignore +5 -0
- agi_diy-0.1.0/PKG-INFO +60 -0
- agi_diy-0.1.0/README.md +44 -0
- agi_diy-0.1.0/agi/__init__.py +43 -0
- agi_diy-0.1.0/agi/_http.py +78 -0
- agi_diy-0.1.0/agi/cli.py +178 -0
- agi_diy-0.1.0/agi/errors.py +56 -0
- agi_diy-0.1.0/agi/session.py +239 -0
- agi_diy-0.1.0/pyproject.toml +27 -0
- agi_diy-0.1.0/tests/README.md +10 -0
- agi_diy-0.1.0/tests/conftest.py +16 -0
- agi_diy-0.1.0/tests/fake_worker.py +111 -0
- agi_diy-0.1.0/tests/loopback_server.py +110 -0
- agi_diy-0.1.0/tests/test_rest.py +100 -0
- agi_diy-0.1.0/tests/test_zenoh_loopback.py +66 -0
agi_diy-0.1.0/.gitignore
ADDED
agi_diy-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agi-diy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: api.agi.diy client: lease a VLA policy, run it over the LeRobot remote-inference protocol.
|
|
5
|
+
Project-URL: Homepage, https://api.agi.diy
|
|
6
|
+
Project-URL: Source, https://github.com/cagataycali/api-agi-diy
|
|
7
|
+
Author: agi.diy
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
12
|
+
Provides-Extra: lerobot
|
|
13
|
+
Requires-Dist: lerobot[remote]>=0.6; extra == 'lerobot'
|
|
14
|
+
Requires-Dist: numpy; extra == 'lerobot'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# agi-diy
|
|
18
|
+
|
|
19
|
+
Python client for [api.agi.diy](https://api.agi.diy). Lease a VLA policy, run it over the LeRobot
|
|
20
|
+
remote-inference protocol (zenoh). One call = one token per action in the chunk.
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
pip install "agi-diy[lerobot]"
|
|
24
|
+
agi login
|
|
25
|
+
agi run act/so101 --probe
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import agi
|
|
30
|
+
|
|
31
|
+
with agi.Policy("act/so101") as session:
|
|
32
|
+
actions = session.predict(
|
|
33
|
+
{"observation.state": state, "observation.images.front": frame},
|
|
34
|
+
task="pick up the cube",
|
|
35
|
+
) # np.ndarray [n_action_steps, dof]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The key comes from `AGI_API_KEY` or `~/.agi/credentials` (`AGI_API_KEY=agi_sk_live_…`).
|
|
39
|
+
|
|
40
|
+
## Surface
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
agi.Policy("act/so101") # POST /v1/sessions → lease; raises agi.NoSlots / agi.InsufficientCredits / agi.AuthError
|
|
44
|
+
session.endpoint # "ws/zenoh.api.agi.diy:80"
|
|
45
|
+
session.deployment # "d_…"
|
|
46
|
+
session.url # "zenoh+ws://zenoh.api.agi.diy:80/d_…"
|
|
47
|
+
session.connect() # admitted lerobot.remote_inference.RemoteClient
|
|
48
|
+
session.predict(obs, task="…") # one chunk, np.ndarray
|
|
49
|
+
session.policy() # strands-robots policy: arm.run(session.policy(), seconds=30)
|
|
50
|
+
session.rollout_args() # flags for lerobot-rollout
|
|
51
|
+
session.close() # DELETE /v1/sessions/:id (also a context manager)
|
|
52
|
+
agi.models(); agi.me(); agi.usage(); agi.sessions(); agi.health()
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
CLI: `agi login` · `agi models` · `agi session act/so101` · `agi run act/so101 --probe` ·
|
|
56
|
+
`agi rollout act/so101 -- --robot.type=so101_follower …` · `agi usage`.
|
|
57
|
+
|
|
58
|
+
`connect()`, `predict()` and `rollout` need `pip install "agi-diy[lerobot]"`. The REST part has no dependencies.
|
|
59
|
+
|
|
60
|
+
Errors subclass `agi.Error` with `.error`, `.status`, `.retry_after_s`.
|
agi_diy-0.1.0/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# agi-diy
|
|
2
|
+
|
|
3
|
+
Python client for [api.agi.diy](https://api.agi.diy). Lease a VLA policy, run it over the LeRobot
|
|
4
|
+
remote-inference protocol (zenoh). One call = one token per action in the chunk.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
pip install "agi-diy[lerobot]"
|
|
8
|
+
agi login
|
|
9
|
+
agi run act/so101 --probe
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
import agi
|
|
14
|
+
|
|
15
|
+
with agi.Policy("act/so101") as session:
|
|
16
|
+
actions = session.predict(
|
|
17
|
+
{"observation.state": state, "observation.images.front": frame},
|
|
18
|
+
task="pick up the cube",
|
|
19
|
+
) # np.ndarray [n_action_steps, dof]
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The key comes from `AGI_API_KEY` or `~/.agi/credentials` (`AGI_API_KEY=agi_sk_live_…`).
|
|
23
|
+
|
|
24
|
+
## Surface
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
agi.Policy("act/so101") # POST /v1/sessions → lease; raises agi.NoSlots / agi.InsufficientCredits / agi.AuthError
|
|
28
|
+
session.endpoint # "ws/zenoh.api.agi.diy:80"
|
|
29
|
+
session.deployment # "d_…"
|
|
30
|
+
session.url # "zenoh+ws://zenoh.api.agi.diy:80/d_…"
|
|
31
|
+
session.connect() # admitted lerobot.remote_inference.RemoteClient
|
|
32
|
+
session.predict(obs, task="…") # one chunk, np.ndarray
|
|
33
|
+
session.policy() # strands-robots policy: arm.run(session.policy(), seconds=30)
|
|
34
|
+
session.rollout_args() # flags for lerobot-rollout
|
|
35
|
+
session.close() # DELETE /v1/sessions/:id (also a context manager)
|
|
36
|
+
agi.models(); agi.me(); agi.usage(); agi.sessions(); agi.health()
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
CLI: `agi login` · `agi models` · `agi session act/so101` · `agi run act/so101 --probe` ·
|
|
40
|
+
`agi rollout act/so101 -- --robot.type=so101_follower …` · `agi usage`.
|
|
41
|
+
|
|
42
|
+
`connect()`, `predict()` and `rollout` need `pip install "agi-diy[lerobot]"`. The REST part has no dependencies.
|
|
43
|
+
|
|
44
|
+
Errors subclass `agi.Error` with `.error`, `.status`, `.retry_after_s`.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""api.agi.diy — lease a VLA policy, run it over zenoh.
|
|
2
|
+
|
|
3
|
+
import agi
|
|
4
|
+
with agi.Policy("act/so101") as session:
|
|
5
|
+
actions = session.predict(observation, task="pick up the cube")
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from . import _http
|
|
11
|
+
from .errors import AuthError, Error, InsufficientCredits, MissingDependency, NoSlots, UnknownModel
|
|
12
|
+
from .session import Policy
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Policy", "models", "me", "usage", "health", "sessions",
|
|
17
|
+
"Error", "AuthError", "NoSlots", "InsufficientCredits", "UnknownModel", "MissingDependency",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def models() -> list[dict]:
|
|
22
|
+
"""GET /v1/models."""
|
|
23
|
+
return _http.request("GET", "/v1/models").get("models", [])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def me() -> dict:
|
|
27
|
+
"""GET /v1/me."""
|
|
28
|
+
return _http.request("GET", "/v1/me")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def usage(since: str | None = None) -> dict:
|
|
32
|
+
"""GET /v1/usage[?since=<iso>]."""
|
|
33
|
+
return _http.request("GET", "/v1/usage" + (f"?since={since}" if since else ""))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def sessions() -> list[dict]:
|
|
37
|
+
"""GET /v1/sessions."""
|
|
38
|
+
return _http.request("GET", "/v1/sessions").get("sessions", [])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def health() -> dict:
|
|
42
|
+
"""GET /v1/health (no key needed)."""
|
|
43
|
+
return _http.request("GET", "/v1/health", auth=False)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Credentials + one JSON request helper. Standard library only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .errors import AuthError, from_response
|
|
12
|
+
|
|
13
|
+
DEFAULT_URL = "https://api.agi.diy"
|
|
14
|
+
CREDENTIALS = Path(os.environ.get("AGI_CREDENTIALS", "~/.agi/credentials")).expanduser()
|
|
15
|
+
USER_AGENT = "agi-diy-python/0.1.0"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _read_credentials() -> dict[str, str]:
|
|
19
|
+
out: dict[str, str] = {}
|
|
20
|
+
try:
|
|
21
|
+
for line in CREDENTIALS.read_text().splitlines():
|
|
22
|
+
line = line.strip()
|
|
23
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
24
|
+
continue
|
|
25
|
+
k, v = line.split("=", 1)
|
|
26
|
+
out[k.strip()] = v.strip().strip('"').strip("'")
|
|
27
|
+
except FileNotFoundError:
|
|
28
|
+
pass
|
|
29
|
+
return out
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def api_url() -> str:
|
|
33
|
+
return (os.environ.get("AGI_API_URL") or _read_credentials().get("AGI_API_URL") or DEFAULT_URL).rstrip("/")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def api_key(required: bool = True) -> str | None:
|
|
37
|
+
key = os.environ.get("AGI_API_KEY") or _read_credentials().get("AGI_API_KEY")
|
|
38
|
+
if not key and required:
|
|
39
|
+
raise AuthError(
|
|
40
|
+
f"Set AGI_API_KEY or run `agi login` (writes {CREDENTIALS}); no API key found.",
|
|
41
|
+
error="unauthorized",
|
|
42
|
+
status=0,
|
|
43
|
+
)
|
|
44
|
+
return key
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def write_credentials(key: str, url: str | None = None) -> Path:
|
|
48
|
+
CREDENTIALS.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
lines = [f"AGI_API_KEY={key}"]
|
|
50
|
+
if url and url.rstrip("/") != DEFAULT_URL:
|
|
51
|
+
lines.append(f"AGI_API_URL={url.rstrip('/')}")
|
|
52
|
+
CREDENTIALS.write_text("\n".join(lines) + "\n")
|
|
53
|
+
CREDENTIALS.chmod(0o600)
|
|
54
|
+
return CREDENTIALS
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def request(method: str, path: str, body: dict | None = None, *, key: str | None = None, auth: bool = True, timeout: float = 15.0) -> dict:
|
|
58
|
+
url = api_url() + path
|
|
59
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
60
|
+
headers = {"Accept": "application/json", "User-Agent": USER_AGENT}
|
|
61
|
+
if data is not None:
|
|
62
|
+
headers["Content-Type"] = "application/json"
|
|
63
|
+
if auth:
|
|
64
|
+
headers["Authorization"] = f"Bearer {key or api_key()}"
|
|
65
|
+
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
66
|
+
try:
|
|
67
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
68
|
+
raw = resp.read()
|
|
69
|
+
return json.loads(raw) if raw else {}
|
|
70
|
+
except urllib.error.HTTPError as exc:
|
|
71
|
+
raw = exc.read()
|
|
72
|
+
try:
|
|
73
|
+
parsed = json.loads(raw) if raw else None
|
|
74
|
+
except ValueError:
|
|
75
|
+
parsed = None
|
|
76
|
+
raise from_response(exc.code, parsed) from None
|
|
77
|
+
except urllib.error.URLError as exc:
|
|
78
|
+
raise from_response(0, {"error": "unreachable", "message": f"Cannot reach {url}: {exc.reason}."}) from None
|
agi_diy-0.1.0/agi/cli.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""`agi` — login, models, run --probe, rollout. Output is plain text or JSON (--json)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import getpass
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
from . import __version__, _http
|
|
14
|
+
from .errors import Error
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _die(exc: Exception, code: int = 1) -> int:
|
|
18
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
19
|
+
return code
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def cmd_login(a: argparse.Namespace) -> int:
|
|
23
|
+
key = a.key or os.environ.get("AGI_API_KEY") or getpass.getpass("API key (agi_sk_live_…): ").strip()
|
|
24
|
+
if not key.startswith("agi_sk_"):
|
|
25
|
+
print("error: keys start with agi_sk_live_; create one at https://api.agi.diy/dashboard", file=sys.stderr)
|
|
26
|
+
return 2
|
|
27
|
+
if a.url:
|
|
28
|
+
os.environ["AGI_API_URL"] = a.url
|
|
29
|
+
try:
|
|
30
|
+
me = _http.request("GET", "/v1/me", key=key)
|
|
31
|
+
except Error as exc:
|
|
32
|
+
return _die(exc)
|
|
33
|
+
path = _http.write_credentials(key, a.url)
|
|
34
|
+
print(f"logged in as {me.get('login')} · credits {me.get('credits_tokens')} tokens · saved to {path}")
|
|
35
|
+
return 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cmd_models(a: argparse.Namespace) -> int:
|
|
39
|
+
try:
|
|
40
|
+
models = _http.request("GET", "/v1/models").get("models", [])
|
|
41
|
+
except Error as exc:
|
|
42
|
+
return _die(exc)
|
|
43
|
+
if a.json:
|
|
44
|
+
print(json.dumps(models, indent=2))
|
|
45
|
+
return 0
|
|
46
|
+
if not models:
|
|
47
|
+
print("no models")
|
|
48
|
+
return 0
|
|
49
|
+
w = max(len(m["id"]) for m in models)
|
|
50
|
+
for m in models:
|
|
51
|
+
print(f"{m['id']:<{w}} {m.get('state','?'):<8} slots {m.get('slots_free','?')}/{m.get('slots_total','?')} "
|
|
52
|
+
f"horizon {m.get('action_horizon','?')} fps {m.get('fps','?')} {m.get('semantics','')}")
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_me(a: argparse.Namespace) -> int:
|
|
57
|
+
try:
|
|
58
|
+
print(json.dumps(_http.request("GET", "/v1/me"), indent=2))
|
|
59
|
+
except Error as exc:
|
|
60
|
+
return _die(exc)
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cmd_usage(a: argparse.Namespace) -> int:
|
|
65
|
+
try:
|
|
66
|
+
u = _http.request("GET", "/v1/usage")
|
|
67
|
+
except Error as exc:
|
|
68
|
+
return _die(exc)
|
|
69
|
+
if a.json:
|
|
70
|
+
print(json.dumps(u, indent=2))
|
|
71
|
+
return 0
|
|
72
|
+
print(f"credits {u.get('credits_tokens')} tokens · used {u.get('tokens_used')} tokens in {u.get('calls')} calls")
|
|
73
|
+
for row in u.get("by_model", []):
|
|
74
|
+
print(f" {row['model']:<16} {row['calls']:>6} calls {row['tokens']:>9} tokens")
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def cmd_session(a: argparse.Namespace) -> int:
|
|
79
|
+
from .session import Policy
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
s = Policy(a.model, ttl_s=a.ttl)
|
|
83
|
+
except Error as exc:
|
|
84
|
+
return _die(exc)
|
|
85
|
+
print(json.dumps(s.lease, indent=2) if a.json else
|
|
86
|
+
f"session {s.id}\nendpoint {s.endpoint}\ndeployment {s.deployment}\nsemantics {s.semantics}\nurl {s.url}\nexpires {s.expires_at}")
|
|
87
|
+
if not a.keep:
|
|
88
|
+
s.close()
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def cmd_run(a: argparse.Namespace) -> int:
|
|
93
|
+
from .session import Policy
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
session = Policy(a.model, ttl_s=a.ttl)
|
|
97
|
+
except Error as exc:
|
|
98
|
+
return _die(exc)
|
|
99
|
+
try:
|
|
100
|
+
print(f"session {session.id} · {session.endpoint} · {session.deployment}")
|
|
101
|
+
t0 = time.perf_counter()
|
|
102
|
+
client = session.connect(chunk_merge="append")
|
|
103
|
+
caps = client.capabilities
|
|
104
|
+
print(f"connected in {(time.perf_counter()-t0)*1000:.0f} ms · features "
|
|
105
|
+
+ ", ".join(f"{f.name}{tuple(f.shape)}" for f in caps.features)
|
|
106
|
+
+ f" · action {caps.action_feature.shape[0]}×{caps.execution_steps} @ {1/caps.action_interval:g} Hz")
|
|
107
|
+
if a.probe:
|
|
108
|
+
obs = session.synthetic_observation()
|
|
109
|
+
for i in range(a.n):
|
|
110
|
+
t1 = time.perf_counter()
|
|
111
|
+
actions = session.predict(obs, task=a.task)
|
|
112
|
+
dt = (time.perf_counter() - t1) * 1000
|
|
113
|
+
print(f"chunk {i+1}/{a.n}: shape {actions.shape} dtype {actions.dtype} · {dt:.0f} ms · "
|
|
114
|
+
f"first {actions[0][:3].round(2).tolist()}…")
|
|
115
|
+
return 0
|
|
116
|
+
except Error as exc:
|
|
117
|
+
return _die(exc)
|
|
118
|
+
except Exception as exc: # zenoh/lerobot errors already name the fix
|
|
119
|
+
return _die(exc)
|
|
120
|
+
finally:
|
|
121
|
+
session.close()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def cmd_rollout(a: argparse.Namespace) -> int:
|
|
125
|
+
from .session import Policy
|
|
126
|
+
|
|
127
|
+
exe = shutil.which("lerobot-rollout")
|
|
128
|
+
if not exe:
|
|
129
|
+
print("error: install LeRobot with `pip install 'lerobot[remote]'`; lerobot-rollout is not on PATH.", file=sys.stderr)
|
|
130
|
+
return 2
|
|
131
|
+
try:
|
|
132
|
+
session = Policy(a.model, ttl_s=a.ttl)
|
|
133
|
+
except Error as exc:
|
|
134
|
+
return _die(exc)
|
|
135
|
+
argv = [exe, *session.rollout_args(), *a.args]
|
|
136
|
+
print("+ " + " ".join(argv), file=sys.stderr)
|
|
137
|
+
import subprocess
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
return subprocess.call(argv)
|
|
141
|
+
finally:
|
|
142
|
+
session.close()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main(argv: list[str] | None = None) -> int:
|
|
146
|
+
p = argparse.ArgumentParser(prog="agi", description="api.agi.diy — the brain over cloud.")
|
|
147
|
+
p.add_argument("--version", action="version", version=__version__)
|
|
148
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
149
|
+
|
|
150
|
+
s = sub.add_parser("login", help="save an API key to ~/.agi/credentials")
|
|
151
|
+
s.add_argument("--key"); s.add_argument("--url", help="API base URL (default https://api.agi.diy)")
|
|
152
|
+
s.set_defaults(fn=cmd_login)
|
|
153
|
+
|
|
154
|
+
s = sub.add_parser("models", help="list models"); s.add_argument("--json", action="store_true"); s.set_defaults(fn=cmd_models)
|
|
155
|
+
s = sub.add_parser("me", help="who am I"); s.set_defaults(fn=cmd_me)
|
|
156
|
+
s = sub.add_parser("usage", help="credits and usage"); s.add_argument("--json", action="store_true"); s.set_defaults(fn=cmd_usage)
|
|
157
|
+
|
|
158
|
+
s = sub.add_parser("session", help="lease a slot and print endpoint + deployment")
|
|
159
|
+
s.add_argument("model"); s.add_argument("--ttl", type=int); s.add_argument("--keep", action="store_true", help="do not end it")
|
|
160
|
+
s.add_argument("--json", action="store_true"); s.set_defaults(fn=cmd_session)
|
|
161
|
+
|
|
162
|
+
s = sub.add_parser("run", help="lease, connect over zenoh, --probe sends a synthetic observation")
|
|
163
|
+
s.add_argument("model"); s.add_argument("--probe", action="store_true"); s.add_argument("-n", type=int, default=3)
|
|
164
|
+
s.add_argument("--task", default="probe"); s.add_argument("--ttl", type=int); s.set_defaults(fn=cmd_run)
|
|
165
|
+
|
|
166
|
+
s = sub.add_parser("rollout", help="lease, then exec lerobot-rollout with the remote flags filled in")
|
|
167
|
+
s.add_argument("model"); s.add_argument("--ttl", type=int)
|
|
168
|
+
s.add_argument("args", nargs=argparse.REMAINDER, help="-- <lerobot-rollout args>")
|
|
169
|
+
s.set_defaults(fn=cmd_rollout)
|
|
170
|
+
|
|
171
|
+
a = p.parse_args(argv)
|
|
172
|
+
if getattr(a, "args", None) and a.args[:1] == ["--"]:
|
|
173
|
+
a.args = a.args[1:]
|
|
174
|
+
return a.fn(a)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
sys.exit(main())
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Every error names the fix in one sentence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Error(Exception):
|
|
7
|
+
"""An api.agi.diy refusal. `error` is the snake_case code from the API."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, *, error: str = "error", status: int = 0, retry_after_s: float | None = None):
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.error = error
|
|
12
|
+
self.status = status
|
|
13
|
+
self.retry_after_s = retry_after_s
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def retryable(self) -> bool:
|
|
17
|
+
return self.retry_after_s is not None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthError(Error):
|
|
21
|
+
"""401: no key, a revoked key, or a wrong key."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NoSlots(Error):
|
|
25
|
+
"""409 no_slots: every slot for the model is taken; wait `retry_after_s` and call again."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InsufficientCredits(Error):
|
|
29
|
+
"""402: credits are at zero."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UnknownModel(Error):
|
|
33
|
+
"""404 unknown_model: the id is not in `agi models`."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MissingDependency(Error):
|
|
37
|
+
"""A zenoh call needs the lerobot extra."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_BY_CODE = {
|
|
41
|
+
"unauthorized": AuthError,
|
|
42
|
+
"no_slots": NoSlots,
|
|
43
|
+
"insufficient_credits": InsufficientCredits,
|
|
44
|
+
"unknown_model": UnknownModel,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def from_response(status: int, body: dict | None) -> Error:
|
|
49
|
+
body = body or {}
|
|
50
|
+
code = str(body.get("error") or f"http_{status}")
|
|
51
|
+
message = str(body.get("message") or f"api.agi.diy answered {status}.")
|
|
52
|
+
retry = body.get("retry_after_s")
|
|
53
|
+
if code == "unauthorized":
|
|
54
|
+
message = "The API key is missing or invalid; set AGI_API_KEY or run `agi login`."
|
|
55
|
+
cls = _BY_CODE.get(code, Error)
|
|
56
|
+
return cls(message, error=code, status=status, retry_after_s=float(retry) if retry is not None else None)
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""agi.Policy: one leased slot on api.agi.diy, spoken over zenoh with LeRobot's own client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from . import _http
|
|
10
|
+
from .errors import MissingDependency
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _lerobot():
|
|
14
|
+
try:
|
|
15
|
+
from lerobot.inference import RemoteInferenceConfig # noqa: F401
|
|
16
|
+
from lerobot.remote_inference import RemoteClient # noqa: F401
|
|
17
|
+
except ImportError:
|
|
18
|
+
raise MissingDependency(
|
|
19
|
+
"Run `pip install 'agi-diy[lerobot]'`; connect() and predict() need lerobot[remote].",
|
|
20
|
+
error="missing_dependency",
|
|
21
|
+
) from None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Policy:
|
|
25
|
+
"""`agi.Policy("act/so101")` leases one slot and holds it until `close()`.
|
|
26
|
+
|
|
27
|
+
Attributes come straight from the lease: `id`, `model`, `endpoint` ("ws/zenoh.api.agi.diy:80"),
|
|
28
|
+
`deployment` ("d_…"), `semantics`, `action_horizon`, `fps`, `expires_at`, `zenoh_mode`.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, model: str, *, ttl_s: int | None = None, api_key: str | None = None):
|
|
32
|
+
body: dict[str, Any] = {"model": model}
|
|
33
|
+
if ttl_s is not None:
|
|
34
|
+
body["ttl_s"] = int(ttl_s)
|
|
35
|
+
self._key = api_key
|
|
36
|
+
self.lease = _http.request("POST", "/v1/sessions", body, key=api_key)
|
|
37
|
+
self._client: Any = None
|
|
38
|
+
self._closed = False
|
|
39
|
+
|
|
40
|
+
# --- lease fields -------------------------------------------------------
|
|
41
|
+
@property
|
|
42
|
+
def id(self) -> str:
|
|
43
|
+
return self.lease["id"]
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def model(self) -> str:
|
|
47
|
+
return self.lease["model"]
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def deployment(self) -> str:
|
|
51
|
+
return self.lease["deployment"]
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def endpoint(self) -> str:
|
|
55
|
+
z = self.lease.get("zenoh") or {}
|
|
56
|
+
return z.get("endpoint") or (z.get("endpoints") or [""])[0]
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def endpoints(self) -> list[str]:
|
|
60
|
+
z = self.lease.get("zenoh") or {}
|
|
61
|
+
return list(z.get("endpoints") or ([z["endpoint"]] if z.get("endpoint") else []))
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def zenoh_mode(self) -> str:
|
|
65
|
+
return (self.lease.get("zenoh") or {}).get("mode") or "client"
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def semantics(self) -> str:
|
|
69
|
+
return self.lease.get("semantics") or ""
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def action_horizon(self) -> int | None:
|
|
73
|
+
return self.lease.get("action_horizon")
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def fps(self) -> int | None:
|
|
77
|
+
return self.lease.get("fps")
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def expires_at(self) -> str | None:
|
|
81
|
+
return self.lease.get("expires_at")
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def url(self) -> str:
|
|
85
|
+
"""`zenoh+<link>://host:port/<deployment>` — e.g. `zenoh+ws://zenoh.api.agi.diy:80/d_…`.
|
|
86
|
+
|
|
87
|
+
A tcp endpoint gives `zenoh://host:port/<deployment>`, which strands-robots `Policy(...)` parses today.
|
|
88
|
+
For ws use `session.policy()` (works now) or the one-line parser change in docs/UPSTREAM.md.
|
|
89
|
+
"""
|
|
90
|
+
link, _, hostport = self.endpoint.partition("/")
|
|
91
|
+
scheme = "zenoh" if link == "tcp" else f"zenoh+{link}"
|
|
92
|
+
return f"{scheme}://{hostport}/{self.deployment}"
|
|
93
|
+
|
|
94
|
+
def rollout_args(self) -> list[str]:
|
|
95
|
+
"""The `lerobot-rollout` flags for this lease."""
|
|
96
|
+
args = [
|
|
97
|
+
"--inference.type=remote",
|
|
98
|
+
f"--inference.endpoint={self.endpoint}",
|
|
99
|
+
f"--inference.deployment={self.deployment}",
|
|
100
|
+
f"--inference.zenoh_mode={self.zenoh_mode}",
|
|
101
|
+
]
|
|
102
|
+
if self.semantics:
|
|
103
|
+
args.append(f"--inference.semantics={self.semantics}")
|
|
104
|
+
return args
|
|
105
|
+
|
|
106
|
+
# --- zenoh --------------------------------------------------------------
|
|
107
|
+
def config(self, **overrides: Any):
|
|
108
|
+
"""A `lerobot.inference.RemoteInferenceConfig` built from the lease."""
|
|
109
|
+
_lerobot()
|
|
110
|
+
from lerobot.inference import RemoteInferenceConfig
|
|
111
|
+
|
|
112
|
+
kw: dict[str, Any] = dict(
|
|
113
|
+
endpoint=self.endpoint,
|
|
114
|
+
deployment=self.deployment,
|
|
115
|
+
semantics=self.semantics or "unknown",
|
|
116
|
+
zenoh_mode=self.zenoh_mode,
|
|
117
|
+
)
|
|
118
|
+
kw.update(overrides)
|
|
119
|
+
return RemoteInferenceConfig(**kw)
|
|
120
|
+
|
|
121
|
+
def connect(self, **overrides: Any):
|
|
122
|
+
"""A connected, admitted `lerobot.remote_inference.RemoteClient` for this lease."""
|
|
123
|
+
_lerobot()
|
|
124
|
+
from lerobot.remote_inference import RemoteClient
|
|
125
|
+
|
|
126
|
+
config = self.config(**overrides)
|
|
127
|
+
client = RemoteClient.connect(config)
|
|
128
|
+
try:
|
|
129
|
+
caps = client.capabilities
|
|
130
|
+
semantics = client.descriptor.get("semantics") or config.semantics
|
|
131
|
+
client.admit(
|
|
132
|
+
features=caps.features,
|
|
133
|
+
action_feature=caps.action_feature,
|
|
134
|
+
semantics=semantics,
|
|
135
|
+
action_interval=caps.action_interval,
|
|
136
|
+
mode=config.mode,
|
|
137
|
+
)
|
|
138
|
+
except Exception:
|
|
139
|
+
client.close()
|
|
140
|
+
raise
|
|
141
|
+
self._client = client
|
|
142
|
+
return client
|
|
143
|
+
|
|
144
|
+
def policy(self, **settings: Any):
|
|
145
|
+
"""A strands-robots policy for this lease: `arm.run(session.policy(), seconds=30)`."""
|
|
146
|
+
try:
|
|
147
|
+
from strands_robots.policy.lerobot import ZenohPolicy
|
|
148
|
+
except ImportError:
|
|
149
|
+
raise MissingDependency(
|
|
150
|
+
"Run `pip install strands-robots`; session.policy() returns a strands-robots ZenohPolicy.",
|
|
151
|
+
error="missing_dependency",
|
|
152
|
+
) from None
|
|
153
|
+
settings.setdefault("zenoh_mode", self.zenoh_mode)
|
|
154
|
+
return ZenohPolicy(endpoint=self.endpoint, deployment=self.deployment, **settings)
|
|
155
|
+
|
|
156
|
+
def features(self) -> tuple:
|
|
157
|
+
"""The deployment's observation features (name, shape, dtype, kind), from the server descriptor."""
|
|
158
|
+
client = self._client or self.connect(chunk_merge="append")
|
|
159
|
+
return client.capabilities.features
|
|
160
|
+
|
|
161
|
+
def synthetic_observation(self) -> dict:
|
|
162
|
+
"""Zeros for every feature the deployment expects — what `agi run --probe` sends."""
|
|
163
|
+
import numpy as np
|
|
164
|
+
|
|
165
|
+
client = self._client or self.connect(chunk_merge="append")
|
|
166
|
+
obs = {}
|
|
167
|
+
for f in client.capabilities.features:
|
|
168
|
+
obs[f.name] = np.zeros(tuple(f.shape), dtype=f.dtype)
|
|
169
|
+
return obs
|
|
170
|
+
|
|
171
|
+
def predict(self, observation: dict, task: str = "", *, timeout_s: float | None = None):
|
|
172
|
+
"""One observation in, one action chunk out: `np.ndarray [n_action_steps, dof]`.
|
|
173
|
+
|
|
174
|
+
`observation` is keyed by the deployment's feature names, e.g.
|
|
175
|
+
`{"observation.state": float32[6], "observation.images.front": uint8[H, W, 3]}`.
|
|
176
|
+
"""
|
|
177
|
+
_lerobot()
|
|
178
|
+
import numpy as np
|
|
179
|
+
from lerobot.inference import ChunkRequest, ExecutionMode, ObservationSnapshot
|
|
180
|
+
from lerobot.policies.rtc.action_queue import QueueSnapshot
|
|
181
|
+
|
|
182
|
+
client = self._client or self.connect(chunk_merge="append")
|
|
183
|
+
caps = client.capabilities
|
|
184
|
+
features = {}
|
|
185
|
+
for spec in caps.features:
|
|
186
|
+
if spec.name not in observation:
|
|
187
|
+
raise KeyError(f"Observation is missing {spec.name!r}; the deployment expects {[f.name for f in caps.features]}.")
|
|
188
|
+
features[spec.name] = np.ascontiguousarray(np.asarray(observation[spec.name]), dtype=spec.dtype)
|
|
189
|
+
aligned = client.chunk_settings.get("chunk_merge") == "aligned"
|
|
190
|
+
snapshot = ObservationSnapshot(
|
|
191
|
+
features=features,
|
|
192
|
+
capture_time=time.monotonic(),
|
|
193
|
+
task=task,
|
|
194
|
+
observation_id=uuid4().hex,
|
|
195
|
+
action_cursor=0 if aligned else None,
|
|
196
|
+
)
|
|
197
|
+
continuation = QueueSnapshot(generation=client.generation, cursor=0, index=0, model_actions=None, canonical_actions=None, provenance=())
|
|
198
|
+
request = ChunkRequest(
|
|
199
|
+
observation=snapshot,
|
|
200
|
+
continuation=continuation,
|
|
201
|
+
request_id=uuid4().hex,
|
|
202
|
+
generation=client.generation,
|
|
203
|
+
mode=ExecutionMode.CHUNK,
|
|
204
|
+
delay=0,
|
|
205
|
+
submitted_at=time.monotonic(),
|
|
206
|
+
)
|
|
207
|
+
if timeout_s is not None:
|
|
208
|
+
client.config.action_timeout_s = float(timeout_s)
|
|
209
|
+
chunk = client.infer(request)
|
|
210
|
+
return chunk.canonical_actions.numpy()
|
|
211
|
+
|
|
212
|
+
# --- lifecycle ----------------------------------------------------------
|
|
213
|
+
def refresh(self) -> dict:
|
|
214
|
+
self.lease = _http.request("GET", f"/v1/sessions/{self.id}", key=self._key)
|
|
215
|
+
return self.lease
|
|
216
|
+
|
|
217
|
+
def close(self) -> dict | None:
|
|
218
|
+
"""End the lease (DELETE /v1/sessions/:id) and the zenoh session, if any. Idempotent."""
|
|
219
|
+
if self._closed:
|
|
220
|
+
return None
|
|
221
|
+
self._closed = True
|
|
222
|
+
if self._client is not None:
|
|
223
|
+
try:
|
|
224
|
+
self._client.close()
|
|
225
|
+
finally:
|
|
226
|
+
self._client = None
|
|
227
|
+
try:
|
|
228
|
+
return _http.request("DELETE", f"/v1/sessions/{self.id}", key=self._key)
|
|
229
|
+
except Exception:
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
def __enter__(self) -> "Policy":
|
|
233
|
+
return self
|
|
234
|
+
|
|
235
|
+
def __exit__(self, *exc: Any) -> None:
|
|
236
|
+
self.close()
|
|
237
|
+
|
|
238
|
+
def __repr__(self) -> str:
|
|
239
|
+
return f"agi.Policy({self.model!r}, deployment={self.deployment!r}, endpoint={self.endpoint!r})"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agi-diy"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "api.agi.diy client: lease a VLA policy, run it over the LeRobot remote-inference protocol."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "agi.diy" }]
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
lerobot = ["lerobot[remote]>=0.6", "numpy"]
|
|
17
|
+
dev = ["pytest"]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
agi = "agi.cli:main"
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://api.agi.diy"
|
|
24
|
+
Source = "https://github.com/cagataycali/api-agi-diy"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["agi"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# tests
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
uv venv .venv && source .venv/bin/activate
|
|
5
|
+
uv pip install -e ".[dev]" # REST tests only (fake Worker on http.server)
|
|
6
|
+
uv pip install -e "/path/to/lerobot[remote]" # + zenoh loopback tests (real lerobot PolicyServer over ws/)
|
|
7
|
+
PYTHONPATH=tests pytest -q
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
`test_zenoh_loopback.py` skips without `lerobot[remote]`.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from fake_worker import KEY, serve
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.fixture
|
|
8
|
+
def worker(monkeypatch, tmp_path):
|
|
9
|
+
url, state, stop = serve()
|
|
10
|
+
monkeypatch.setenv("AGI_API_URL", url)
|
|
11
|
+
monkeypatch.setenv("AGI_API_KEY", KEY)
|
|
12
|
+
monkeypatch.setenv("AGI_CREDENTIALS", str(tmp_path / "credentials"))
|
|
13
|
+
import importlib, agi._http
|
|
14
|
+
importlib.reload(agi._http)
|
|
15
|
+
yield state
|
|
16
|
+
stop()
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""A tiny api.agi.diy stand-in (docs/CONTRACT.md v0) on http.server, for REST tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
8
|
+
|
|
9
|
+
KEY = "agi_sk_live_" + "a" * 32
|
|
10
|
+
MODEL = {
|
|
11
|
+
"id": "act/so101", "family": "act", "robot": "so101", "semantics": "so101-degrees-gripper-percent-v1",
|
|
12
|
+
"action_horizon": 50, "fps": 30, "cameras": ["front"], "slots_free": 1, "slots_total": 1, "state": "ready",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class State:
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.sessions: dict[str, dict] = {}
|
|
19
|
+
self.free = 1
|
|
20
|
+
self.credits = 200_000
|
|
21
|
+
self.requests: list[tuple[str, str, dict | None]] = []
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def make_handler(state: State):
|
|
25
|
+
class H(BaseHTTPRequestHandler):
|
|
26
|
+
def log_message(self, *a): # quiet
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
def _json(self, code, body):
|
|
30
|
+
raw = json.dumps(body).encode()
|
|
31
|
+
self.send_response(code)
|
|
32
|
+
self.send_header("Content-Type", "application/json")
|
|
33
|
+
self.send_header("Content-Length", str(len(raw)))
|
|
34
|
+
self.end_headers()
|
|
35
|
+
self.wfile.write(raw)
|
|
36
|
+
|
|
37
|
+
def _body(self):
|
|
38
|
+
n = int(self.headers.get("Content-Length") or 0)
|
|
39
|
+
return json.loads(self.rfile.read(n)) if n else None
|
|
40
|
+
|
|
41
|
+
def _auth(self):
|
|
42
|
+
return self.headers.get("Authorization") == f"Bearer {KEY}"
|
|
43
|
+
|
|
44
|
+
def do_GET(self):
|
|
45
|
+
path = self.path.split("?")[0]
|
|
46
|
+
state.requests.append(("GET", path, None))
|
|
47
|
+
if path == "/v1/health":
|
|
48
|
+
return self._json(200, {"ok": True, "runners": 1, "time": "2026-10-24T00:00:00Z"})
|
|
49
|
+
if not self._auth():
|
|
50
|
+
return self._json(401, {"error": "unauthorized", "message": "Missing or invalid API key."})
|
|
51
|
+
if path == "/v1/models":
|
|
52
|
+
return self._json(200, {"models": [MODEL]})
|
|
53
|
+
if path == "/v1/me":
|
|
54
|
+
return self._json(200, {"id": "u_1", "login": "tester", "credits_tokens": state.credits})
|
|
55
|
+
if path == "/v1/usage":
|
|
56
|
+
return self._json(200, {"credits_tokens": state.credits, "tokens_used": 0, "calls": 0, "by_model": [], "events": []})
|
|
57
|
+
if path.startswith("/v1/sessions/"):
|
|
58
|
+
s = state.sessions.get(path.rsplit("/", 1)[1])
|
|
59
|
+
return self._json(200, s) if s else self._json(404, {"error": "not_found", "message": "No such session."})
|
|
60
|
+
if path == "/v1/sessions":
|
|
61
|
+
return self._json(200, {"sessions": list(state.sessions.values())})
|
|
62
|
+
self._json(404, {"error": "not_found", "message": "No such route."})
|
|
63
|
+
|
|
64
|
+
def do_POST(self):
|
|
65
|
+
body = self._body()
|
|
66
|
+
state.requests.append(("POST", self.path, body))
|
|
67
|
+
if not self._auth():
|
|
68
|
+
return self._json(401, {"error": "unauthorized", "message": "Missing or invalid API key."})
|
|
69
|
+
if self.path == "/v1/sessions":
|
|
70
|
+
if body.get("model") != MODEL["id"]:
|
|
71
|
+
return self._json(404, {"error": "unknown_model", "message": "Model not found; see GET /v1/models."})
|
|
72
|
+
if state.credits <= 0:
|
|
73
|
+
return self._json(402, {"error": "insufficient_credits", "message": "Credits are at zero; top up at https://api.agi.diy/dashboard."})
|
|
74
|
+
if state.free <= 0:
|
|
75
|
+
return self._json(409, {"error": "no_slots", "message": "All slots are busy; retry in 5 s.", "retry_after_s": 5})
|
|
76
|
+
state.free -= 1
|
|
77
|
+
sid = f"ses_{len(state.sessions)+1:022d}"
|
|
78
|
+
s = {
|
|
79
|
+
"id": sid, "model": MODEL["id"], "state": "leased", "deployment": "d_test0000000000000000ab", "runner": "thor",
|
|
80
|
+
"zenoh": {"endpoint": "ws/zenoh.api.agi.diy:80", "endpoints": ["ws/zenoh.api.agi.diy:80", "tcp/1.2.3.4:7447"], "mode": "client"},
|
|
81
|
+
"semantics": MODEL["semantics"], "features": {}, "action_horizon": 50, "fps": 30,
|
|
82
|
+
"created_at": "2026-10-24T00:00:00Z", "expires_at": "2026-10-24T00:10:00Z", "ended_at": None,
|
|
83
|
+
"end_reason": None, "tokens_used": 0,
|
|
84
|
+
}
|
|
85
|
+
state.sessions[sid] = s
|
|
86
|
+
return self._json(201, s)
|
|
87
|
+
self._json(404, {"error": "not_found", "message": "No such route."})
|
|
88
|
+
|
|
89
|
+
def do_DELETE(self):
|
|
90
|
+
state.requests.append(("DELETE", self.path, None))
|
|
91
|
+
if not self._auth():
|
|
92
|
+
return self._json(401, {"error": "unauthorized", "message": "Missing or invalid API key."})
|
|
93
|
+
sid = self.path.rsplit("/", 1)[1]
|
|
94
|
+
s = state.sessions.get(sid)
|
|
95
|
+
if not s:
|
|
96
|
+
return self._json(404, {"error": "not_found", "message": "No such session."})
|
|
97
|
+
if s["state"] != "ended":
|
|
98
|
+
s["state"], s["end_reason"] = "ended", "client_done"
|
|
99
|
+
state.free += 1
|
|
100
|
+
self._json(200, s)
|
|
101
|
+
|
|
102
|
+
return H
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def serve():
|
|
106
|
+
"""Start on a free port; returns (base_url, state, shutdown)."""
|
|
107
|
+
state = State()
|
|
108
|
+
srv = ThreadingHTTPServer(("127.0.0.1", 0), make_handler(state))
|
|
109
|
+
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
|
110
|
+
t.start()
|
|
111
|
+
return f"http://127.0.0.1:{srv.server_port}", state, srv.shutdown
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""A real lerobot PolicyServer on a local zenoh ws listener, serving a tiny deterministic policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import socket
|
|
6
|
+
from collections import deque
|
|
7
|
+
from threading import Event, Thread
|
|
8
|
+
|
|
9
|
+
import torch
|
|
10
|
+
from lerobot.configs.types import FeatureType, NormalizationMode, PolicyFeature
|
|
11
|
+
from lerobot.inference import FeatureSpec, PolicyRunner
|
|
12
|
+
from lerobot.policies.act.configuration_act import ACTConfig
|
|
13
|
+
from lerobot.policies.act.processor_act import make_act_pre_post_processors
|
|
14
|
+
from lerobot.policies.pretrained import PreTrainedPolicy
|
|
15
|
+
from lerobot.remote_inference.server import PolicyServer, SessionWorker
|
|
16
|
+
from lerobot.transport.zenoh import ZenohConfig, ZenohTransport
|
|
17
|
+
from lerobot.utils.constants import ACTION, OBS_ENV_STATE, OBS_STATE
|
|
18
|
+
|
|
19
|
+
SEMANTICS = "so101-degrees-gripper-percent-v1"
|
|
20
|
+
NAMES = ("shoulder.pos", "elbow.pos", "gripper.pos")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TinyPolicy(PreTrainedPolicy):
|
|
24
|
+
config_class = ACTConfig
|
|
25
|
+
name = "tiny"
|
|
26
|
+
|
|
27
|
+
def __init__(self, config):
|
|
28
|
+
super().__init__(config)
|
|
29
|
+
self.reset()
|
|
30
|
+
|
|
31
|
+
def get_optim_params(self):
|
|
32
|
+
return {}
|
|
33
|
+
|
|
34
|
+
def reset(self):
|
|
35
|
+
self._action_queue = deque()
|
|
36
|
+
|
|
37
|
+
def forward(self, batch):
|
|
38
|
+
raise NotImplementedError
|
|
39
|
+
|
|
40
|
+
def select_action(self, batch, **kwargs):
|
|
41
|
+
raise AssertionError("the runner must not call select_action")
|
|
42
|
+
|
|
43
|
+
def predict_action_chunk(self, batch, **kwargs):
|
|
44
|
+
return torch.arange(24, dtype=torch.float32).reshape(1, 8, 3)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def free_port() -> int:
|
|
48
|
+
with socket.socket() as s:
|
|
49
|
+
s.bind(("127.0.0.1", 0))
|
|
50
|
+
return s.getsockname()[1]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def serve(deployment: str = "d_loopback", link: str = "ws"):
|
|
54
|
+
"""Start a server thread; returns (endpoint, stop)."""
|
|
55
|
+
config = ACTConfig(
|
|
56
|
+
input_features={
|
|
57
|
+
OBS_STATE: PolicyFeature(type=FeatureType.STATE, shape=(3,)),
|
|
58
|
+
OBS_ENV_STATE: PolicyFeature(type=FeatureType.ENV, shape=(3,)),
|
|
59
|
+
},
|
|
60
|
+
output_features={ACTION: PolicyFeature(type=FeatureType.ACTION, shape=(3,))},
|
|
61
|
+
normalization_mapping={
|
|
62
|
+
FeatureType.STATE: NormalizationMode.MEAN_STD,
|
|
63
|
+
FeatureType.ENV: NormalizationMode.IDENTITY,
|
|
64
|
+
FeatureType.ACTION: NormalizationMode.MEAN_STD,
|
|
65
|
+
},
|
|
66
|
+
chunk_size=8,
|
|
67
|
+
n_action_steps=8,
|
|
68
|
+
dim_model=16,
|
|
69
|
+
n_heads=2,
|
|
70
|
+
dim_feedforward=32,
|
|
71
|
+
n_encoder_layers=1,
|
|
72
|
+
n_decoder_layers=1,
|
|
73
|
+
vision_backbone="resnet18",
|
|
74
|
+
pretrained_backbone_weights=None,
|
|
75
|
+
device="cpu",
|
|
76
|
+
)
|
|
77
|
+
stats = {
|
|
78
|
+
OBS_STATE: {"mean": torch.zeros(3), "std": torch.ones(3)},
|
|
79
|
+
ACTION: {"mean": torch.tensor([1.0, 2.0, 3.0]), "std": torch.ones(3) * 2},
|
|
80
|
+
}
|
|
81
|
+
pre, post = make_act_pre_post_processors(config, stats)
|
|
82
|
+
runner = PolicyRunner(
|
|
83
|
+
TinyPolicy(config), pre, post, action_interval=1 / 30,
|
|
84
|
+
features=tuple(FeatureSpec(k, (3,), "float32", names=NAMES, semantics=SEMANTICS) for k in (OBS_STATE, OBS_ENV_STATE)),
|
|
85
|
+
action_feature=FeatureSpec(ACTION, (3,), "float32", names=NAMES, semantics=SEMANTICS),
|
|
86
|
+
)
|
|
87
|
+
worker = SessionWorker(runner, deployment=deployment, artifact_identity="tiny-loopback", semantics=SEMANTICS)
|
|
88
|
+
endpoint = f"{link}/127.0.0.1:{free_port()}"
|
|
89
|
+
transport = ZenohTransport(ZenohConfig(listen_endpoints=[endpoint]))
|
|
90
|
+
server = PolicyServer(worker, transport)
|
|
91
|
+
ready = Event()
|
|
92
|
+
original = transport.declare_token
|
|
93
|
+
|
|
94
|
+
def declare_token(key):
|
|
95
|
+
token = original(key)
|
|
96
|
+
ready.set()
|
|
97
|
+
return token
|
|
98
|
+
|
|
99
|
+
transport.declare_token = declare_token
|
|
100
|
+
thread = Thread(target=server.serve, daemon=True)
|
|
101
|
+
thread.start()
|
|
102
|
+
assert ready.wait(30), "loopback server did not advertise readiness"
|
|
103
|
+
|
|
104
|
+
def stop():
|
|
105
|
+
server.stop()
|
|
106
|
+
thread.join(10)
|
|
107
|
+
transport.close()
|
|
108
|
+
worker.close()
|
|
109
|
+
|
|
110
|
+
return endpoint, stop
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import agi
|
|
8
|
+
from agi import _http
|
|
9
|
+
from fake_worker import KEY
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_models_me_usage_health(worker):
|
|
13
|
+
assert agi.models()[0]["id"] == "act/so101"
|
|
14
|
+
assert agi.me()["login"] == "tester"
|
|
15
|
+
assert agi.usage()["credits_tokens"] == 200_000
|
|
16
|
+
assert agi.health()["ok"] is True
|
|
17
|
+
assert ("GET", "/v1/health", None) in worker.requests
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_policy_lease_and_close(worker):
|
|
21
|
+
with agi.Policy("act/so101", ttl_s=120) as s:
|
|
22
|
+
assert s.endpoint == "ws/zenoh.api.agi.diy:80"
|
|
23
|
+
assert s.endpoints[1] == "tcp/1.2.3.4:7447"
|
|
24
|
+
assert s.deployment.startswith("d_")
|
|
25
|
+
assert s.zenoh_mode == "client"
|
|
26
|
+
assert s.url == "zenoh+ws://zenoh.api.agi.diy:80/d_test0000000000000000ab"
|
|
27
|
+
assert "--inference.endpoint=ws/zenoh.api.agi.diy:80" in s.rollout_args()
|
|
28
|
+
assert f"--inference.deployment={s.deployment}" in s.rollout_args()
|
|
29
|
+
assert "--inference.semantics=so101-degrees-gripper-percent-v1" in s.rollout_args()
|
|
30
|
+
assert worker.requests[-1] == ("POST", "/v1/sessions", {"model": "act/so101", "ttl_s": 120})
|
|
31
|
+
assert worker.requests[-1] == ("DELETE", f"/v1/sessions/{s.id}", None)
|
|
32
|
+
assert worker.free == 1
|
|
33
|
+
assert s.close() is None # idempotent
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_errors(worker, monkeypatch):
|
|
37
|
+
with pytest.raises(agi.UnknownModel) as e:
|
|
38
|
+
agi.Policy("nope/so101")
|
|
39
|
+
assert e.value.error == "unknown_model" and e.value.status == 404
|
|
40
|
+
|
|
41
|
+
worker.free = 0
|
|
42
|
+
with pytest.raises(agi.NoSlots) as e:
|
|
43
|
+
agi.Policy("act/so101")
|
|
44
|
+
assert e.value.retry_after_s == 5 and e.value.retryable
|
|
45
|
+
|
|
46
|
+
worker.free, worker.credits = 1, 0
|
|
47
|
+
with pytest.raises(agi.InsufficientCredits):
|
|
48
|
+
agi.Policy("act/so101")
|
|
49
|
+
|
|
50
|
+
monkeypatch.setenv("AGI_API_KEY", "agi_sk_live_wrong")
|
|
51
|
+
with pytest.raises(agi.AuthError) as e:
|
|
52
|
+
agi.models()
|
|
53
|
+
assert "agi login" in str(e.value)
|
|
54
|
+
|
|
55
|
+
monkeypatch.delenv("AGI_API_KEY")
|
|
56
|
+
with pytest.raises(agi.AuthError):
|
|
57
|
+
agi.models()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_credentials_file(worker, monkeypatch, tmp_path):
|
|
61
|
+
monkeypatch.delenv("AGI_API_KEY")
|
|
62
|
+
path = _http.write_credentials(KEY, os.environ["AGI_API_URL"])
|
|
63
|
+
assert path == tmp_path / "credentials"
|
|
64
|
+
assert oct(path.stat().st_mode & 0o777) == "0o600"
|
|
65
|
+
assert path.read_text().splitlines()[0] == f"AGI_API_KEY={KEY}"
|
|
66
|
+
assert agi.me()["login"] == "tester"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_missing_lerobot_names_fix(worker, monkeypatch):
|
|
70
|
+
import builtins
|
|
71
|
+
real = builtins.__import__
|
|
72
|
+
|
|
73
|
+
def fake(name, *a, **k):
|
|
74
|
+
if name.startswith("lerobot"):
|
|
75
|
+
raise ImportError(name)
|
|
76
|
+
return real(name, *a, **k)
|
|
77
|
+
|
|
78
|
+
monkeypatch.setattr(builtins, "__import__", fake)
|
|
79
|
+
s = agi.Policy("act/so101")
|
|
80
|
+
with pytest.raises(agi.MissingDependency) as e:
|
|
81
|
+
s.connect()
|
|
82
|
+
assert "agi-diy[lerobot]" in str(e.value)
|
|
83
|
+
s.close()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _cli(*args):
|
|
87
|
+
return subprocess.run([sys.executable, "-m", "agi.cli", *args], capture_output=True, text=True, env=os.environ)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_cli(worker):
|
|
91
|
+
r = _cli("models")
|
|
92
|
+
assert r.returncode == 0 and "act/so101" in r.stdout and "ready" in r.stdout
|
|
93
|
+
r = _cli("session", "act/so101")
|
|
94
|
+
assert r.returncode == 0 and "ws/zenoh.api.agi.diy:80" in r.stdout and worker.free == 1
|
|
95
|
+
r = _cli("login", "--key", KEY, "--url", os.environ["AGI_API_URL"])
|
|
96
|
+
assert r.returncode == 0 and "tester" in r.stdout, r.stderr
|
|
97
|
+
r = _cli("login", "--key", "agi_sk_live_bad")
|
|
98
|
+
assert r.returncode == 1 and "agi login" in r.stderr
|
|
99
|
+
r = _cli("usage")
|
|
100
|
+
assert r.returncode == 0 and "credits 200000" in r.stdout
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""End-to-end against fakes: fake Worker leases a slot whose endpoint is a REAL lerobot PolicyServer over zenoh ws."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
pytest.importorskip("zenoh")
|
|
7
|
+
pytest.importorskip("lerobot.remote_inference")
|
|
8
|
+
|
|
9
|
+
import agi
|
|
10
|
+
from loopback_server import SEMANTICS, serve
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(scope="module")
|
|
14
|
+
def server():
|
|
15
|
+
endpoint, stop = serve(deployment="d_loopback", link="ws")
|
|
16
|
+
yield endpoint
|
|
17
|
+
stop()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_predict_one_shot(worker, server, monkeypatch):
|
|
21
|
+
# Patch the fake's lease to point at the loopback server (ws link, zenoh client mode).
|
|
22
|
+
import fake_worker
|
|
23
|
+
monkeypatch.setitem(fake_worker.MODEL, "semantics", SEMANTICS)
|
|
24
|
+
session = agi.Policy("act/so101")
|
|
25
|
+
session.lease["zenoh"] = {"endpoint": server, "endpoints": [server], "mode": "client"}
|
|
26
|
+
session.lease["deployment"] = "d_loopback"
|
|
27
|
+
assert session.endpoint.startswith("ws/127.0.0.1:")
|
|
28
|
+
with session:
|
|
29
|
+
obs = session.synthetic_observation()
|
|
30
|
+
assert set(obs) == {"observation.state", "observation.environment_state"}
|
|
31
|
+
actions = session.predict(obs, task="probe")
|
|
32
|
+
assert isinstance(actions, np.ndarray)
|
|
33
|
+
assert actions.shape == (8, 3) and actions.dtype == np.float32
|
|
34
|
+
# tiny policy: arange(24) de-normalized with mean [1,2,3], std 2 -> [1,4,7], [7,10,13], ...
|
|
35
|
+
np.testing.assert_allclose(actions[0], [1.0, 4.0, 7.0])
|
|
36
|
+
np.testing.assert_allclose(actions[1], [7.0, 10.0, 13.0])
|
|
37
|
+
again = session.predict(obs, task="probe")
|
|
38
|
+
assert again.shape == (8, 3)
|
|
39
|
+
assert worker.free == 1
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_connect_returns_admitted_client(worker, server, monkeypatch):
|
|
43
|
+
import fake_worker
|
|
44
|
+
monkeypatch.setitem(fake_worker.MODEL, "semantics", SEMANTICS)
|
|
45
|
+
session = agi.Policy("act/so101")
|
|
46
|
+
session.lease["zenoh"] = {"endpoint": server, "endpoints": [server], "mode": "client"}
|
|
47
|
+
session.lease["deployment"] = "d_loopback"
|
|
48
|
+
client = session.connect()
|
|
49
|
+
try:
|
|
50
|
+
assert client.session_id # admitted
|
|
51
|
+
assert client.descriptor["deployment"] == "d_loopback"
|
|
52
|
+
assert [f.name for f in client.capabilities.features] == ["observation.state", "observation.environment_state"]
|
|
53
|
+
assert session.config().zenoh_mode == "client"
|
|
54
|
+
finally:
|
|
55
|
+
session.close()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_wrong_deployment_names_fix(worker, server, monkeypatch):
|
|
59
|
+
import fake_worker
|
|
60
|
+
monkeypatch.setitem(fake_worker.MODEL, "semantics", SEMANTICS)
|
|
61
|
+
session = agi.Policy("act/so101")
|
|
62
|
+
session.lease["zenoh"] = {"endpoint": server, "endpoints": [server], "mode": "client"}
|
|
63
|
+
session.lease["deployment"] = "d_nope"
|
|
64
|
+
with pytest.raises(TimeoutError, match="did not answer discovery"):
|
|
65
|
+
session.connect(handshake_timeout_s=1.5)
|
|
66
|
+
session.close()
|