roboapi 0.4.2__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.
- roboapi/__init__.py +2 -0
- roboapi/calibration/__init__.py +6 -0
- roboapi/calibration/profile.py +139 -0
- roboapi/calibration/session.py +350 -0
- roboapi/cli.py +215 -0
- roboapi/config/__init__.py +22 -0
- roboapi/config/loader.py +119 -0
- roboapi/config/models.py +191 -0
- roboapi/embodiment.py +251 -0
- roboapi/errors.py +37 -0
- roboapi/geometry.py +337 -0
- roboapi/hardware/__init__.py +5 -0
- roboapi/hardware/base.py +113 -0
- roboapi/hardware/camera.py +167 -0
- roboapi/hardware/franka/__init__.py +5 -0
- roboapi/hardware/franka/arm.py +476 -0
- roboapi/hardware/franka/desk.py +555 -0
- roboapi/hardware/franka/feedback.py +196 -0
- roboapi/hardware/franka/guiding.py +104 -0
- roboapi/hardware/franka/ik.py +543 -0
- roboapi/hardware/franka/limits.py +110 -0
- roboapi/hardware/franka/settings.py +116 -0
- roboapi/hardware/robotiq.py +350 -0
- roboapi/hardware/ur/__init__.py +16 -0
- roboapi/hardware/ur/build.py +92 -0
- roboapi/hardware/ur/compliant.py +128 -0
- roboapi/hardware/ur/dashboard.py +103 -0
- roboapi/hardware/ur/feedback.py +159 -0
- roboapi/hardware/ur/ipc.py +206 -0
- roboapi/hardware/ur/native/CMakeLists.txt +37 -0
- roboapi/hardware/ur/native/dependencies.lock.json +13 -0
- roboapi/hardware/ur/native/include/ur_cpp/cartesian_speedl.hpp +170 -0
- roboapi/hardware/ur/native/include/ur_cpp/control_loop.hpp +126 -0
- roboapi/hardware/ur/native/include/ur_cpp/geom.hpp +72 -0
- roboapi/hardware/ur/native/include/ur_cpp/motion_primitives.hpp +350 -0
- roboapi/hardware/ur/native/include/ur_cpp/robot_io.hpp +26 -0
- roboapi/hardware/ur/native/include/ur_cpp/streaming_pose.hpp +369 -0
- roboapi/hardware/ur/native/src/control_loop.cpp +512 -0
- roboapi/hardware/ur/native/src/robot_io_urcl.cpp +143 -0
- roboapi/hardware/ur/native/tests/cartesian_speedl_unit.cpp +113 -0
- roboapi/hardware/ur/native/tests/control_modes_unit.cpp +137 -0
- roboapi/hardware/ur/native/tests/streaming_pose_unit.cpp +348 -0
- roboapi/hardware/ur/native/tests/velocity_governor_unit.cpp +65 -0
- roboapi/hardware/ur/native/worker.cpp +147 -0
- roboapi/hardware/ur/position.py +253 -0
- roboapi/hardware/ur/settings.py +95 -0
- roboapi/images.py +16 -0
- roboapi/initialize.py +137 -0
- roboapi/mcp.py +497 -0
- roboapi/motion.py +125 -0
- roboapi/naming.py +7 -0
- roboapi/operations.py +45 -0
- roboapi/policy/__init__.py +7 -0
- roboapi/policy/client.py +277 -0
- roboapi/policy/protocol.py +166 -0
- roboapi/policy/settings.py +79 -0
- roboapi/product-names.json +12 -0
- roboapi/records.py +149 -0
- roboapi/runtime.py +911 -0
- roboapi/semantics.py +6 -0
- roboapi/tasks.py +337 -0
- roboapi/web/__init__.py +5 -0
- roboapi/web/presets.py +44 -0
- roboapi/web/server.py +419 -0
- roboapi/web/static/assets/Bundler-LICENSE.txt +48 -0
- roboapi/web/static/assets/CalibrationScene-BO_spRoL.js +4220 -0
- roboapi/web/static/assets/Inter-OFL.txt +93 -0
- roboapi/web/static/assets/Inter-Variable-c8O0ljhh.ttf +0 -0
- roboapi/web/static/assets/Lucide-LICENSE.txt +43 -0
- roboapi/web/static/assets/Meshoptimizer-LICENSE.txt +23 -0
- roboapi/web/static/assets/MiSans-Regular-ES4DrbaF.woff2 +0 -0
- roboapi/web/static/assets/MiSans-Semibold-B34HIk9C.woff2 +0 -0
- roboapi/web/static/assets/PokeBrand-NOTICE.txt +20 -0
- roboapi/web/static/assets/React-LICENSE.txt +23 -0
- roboapi/web/static/assets/Three-LICENSE.txt +23 -0
- roboapi/web/static/assets/home-reference-CTpf8-oV.png +0 -0
- roboapi/web/static/assets/index-CGLhpUm0.js +28 -0
- roboapi/web/static/assets/index-QXqV-fDC.css +1 -0
- roboapi/web/static/assets/robotiq-ace-pro-2-EJuoOXTy.glb +0 -0
- roboapi/web/static/index.html +15 -0
- roboapi-0.4.2.dist-info/METADATA +205 -0
- roboapi-0.4.2.dist-info/RECORD +94 -0
- roboapi-0.4.2.dist-info/WHEEL +5 -0
- roboapi-0.4.2.dist-info/entry_points.txt +2 -0
- roboapi-0.4.2.dist-info/licenses/LICENSE +202 -0
- roboapi-0.4.2.dist-info/licenses/NOTICE +16 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Bundler-LICENSE.txt +48 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Inter-OFL.txt +93 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Lucide-LICENSE.txt +43 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Meshoptimizer-LICENSE.txt +23 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/PokeBrand-NOTICE.txt +20 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/React-LICENSE.txt +23 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Three-LICENSE.txt +23 -0
- roboapi-0.4.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Session-based model API client and coordinated action chunks."""
|
|
2
|
+
|
|
3
|
+
from ..errors import PolicyUnavailable
|
|
4
|
+
from .client import OpenAIChatPolicyClient
|
|
5
|
+
from .protocol import ActionChunk
|
|
6
|
+
|
|
7
|
+
__all__ = ["ActionChunk", "OpenAIChatPolicyClient", "PolicyUnavailable"]
|
roboapi/policy/client.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""One model session per run, with serialized turns and execution feedback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from contextlib import closing
|
|
11
|
+
from dataclasses import replace
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from ..errors import PolicyUnavailable
|
|
18
|
+
from .protocol import ActionChunk, decode_completion, decode_session, encode_observation
|
|
19
|
+
from .settings import read_token
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _request_id(response) -> str | None:
|
|
25
|
+
value = response.headers.get("X-Request-ID", "")
|
|
26
|
+
return value if re.fullmatch(r"[A-Za-z0-9_.-]{1,128}", value) else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OpenAIChatPolicyClient:
|
|
30
|
+
"""Own the HTTP connection and current session; history stays on the server."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, url: str, token_file: str, model: str, *, image_height: int | None = 360,
|
|
33
|
+
timeout_s: float = 30.0, use_env_proxy: bool = True) -> None:
|
|
34
|
+
self._url = url.rstrip("/")
|
|
35
|
+
self._token_file = token_file
|
|
36
|
+
self._model = model
|
|
37
|
+
self._image_height = image_height
|
|
38
|
+
self._timeout_s = float(timeout_s)
|
|
39
|
+
self._use_env_proxy = use_env_proxy
|
|
40
|
+
self._host = urlsplit(url).hostname or "unknown"
|
|
41
|
+
self._http = None
|
|
42
|
+
self._session_lock = threading.RLock()
|
|
43
|
+
self._session_id: str | None = None
|
|
44
|
+
self._next_turn = 1
|
|
45
|
+
# A failed turn can still close its session. Unknown create/close outcomes
|
|
46
|
+
# are terminal and cannot be retried without preparing a new run.
|
|
47
|
+
self._state = "closed" # closed | starting | open | failed | closing | unknown
|
|
48
|
+
self._inflight = False
|
|
49
|
+
self._pending_messages: list[dict] = []
|
|
50
|
+
self._awaiting_execution: tuple[int, dict] | None = None
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def session_status(self) -> dict:
|
|
54
|
+
with self._session_lock:
|
|
55
|
+
return {"id": self._session_id, "next_turn": self._next_turn,
|
|
56
|
+
"status": "unknown" if self._state == "failed" else self._state}
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def ready(self) -> bool:
|
|
60
|
+
return self._http is not None
|
|
61
|
+
|
|
62
|
+
def _new_http(self, timeout_s: float):
|
|
63
|
+
return httpx.Client(
|
|
64
|
+
base_url=self._url + "/",
|
|
65
|
+
headers={"Authorization": f"Bearer {read_token(self._token_file)}",
|
|
66
|
+
"Content-Type": "application/json", "Accept": "application/json"},
|
|
67
|
+
timeout=timeout_s, trust_env=self._use_env_proxy,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def _diagnostic(self, phase: str, kind: str, started: float, response=None) -> PolicyUnavailable:
|
|
71
|
+
fields = [f"policy {phase} {kind}", f"host={self._host}",
|
|
72
|
+
f"elapsed_ms={(time.perf_counter() - started) * 1e3:.1f}"]
|
|
73
|
+
if response is not None:
|
|
74
|
+
fields.append(f"http_status={response.status_code}")
|
|
75
|
+
if request_id := _request_id(response):
|
|
76
|
+
fields.append(f"request_id={request_id}")
|
|
77
|
+
return PolicyUnavailable("; ".join(fields))
|
|
78
|
+
|
|
79
|
+
def _request(self, phase: str, path: str, *, body=None, http=None, decode=None):
|
|
80
|
+
"""Send once, decode once, and expose only redacted network diagnostics."""
|
|
81
|
+
http = self._http if http is None else http
|
|
82
|
+
started = time.perf_counter()
|
|
83
|
+
try:
|
|
84
|
+
response = http.get(path) if body is None else http.post(path, json=body)
|
|
85
|
+
request_ms = (time.perf_counter() - started) * 1e3
|
|
86
|
+
if response.status_code >= 400:
|
|
87
|
+
kind = {401: "authentication rejected", 403: "model not authorized"}.get(
|
|
88
|
+
response.status_code, "HTTP error",
|
|
89
|
+
)
|
|
90
|
+
raise self._diagnostic(phase, kind, started, response)
|
|
91
|
+
payload = response.json()
|
|
92
|
+
if not isinstance(payload, dict):
|
|
93
|
+
raise ValueError("response must be an object")
|
|
94
|
+
request_id = _request_id(response)
|
|
95
|
+
result = decode(payload, request_id) if decode else payload
|
|
96
|
+
logger.info("policy %s succeeded; host=%s; elapsed_ms=%.1f; http_status=%d; request_id=%s",
|
|
97
|
+
phase, self._host, (time.perf_counter() - started) * 1e3,
|
|
98
|
+
response.status_code, request_id or "none")
|
|
99
|
+
return result, request_ms
|
|
100
|
+
except PolicyUnavailable:
|
|
101
|
+
raise
|
|
102
|
+
except httpx.TransportError as exc:
|
|
103
|
+
kind = ("connect timeout" if isinstance(exc, httpx.ConnectTimeout) else
|
|
104
|
+
"read timeout" if isinstance(exc, httpx.ReadTimeout) else "transport error")
|
|
105
|
+
raise self._diagnostic(phase, kind, started) from None
|
|
106
|
+
except (ValueError, TypeError, KeyError, IndexError, AttributeError, RuntimeError):
|
|
107
|
+
raise self._diagnostic(phase, "invalid response", started) from None
|
|
108
|
+
|
|
109
|
+
def connect(self) -> None:
|
|
110
|
+
if self.ready:
|
|
111
|
+
return
|
|
112
|
+
http = self._new_http(self._timeout_s)
|
|
113
|
+
if self._url.startswith("http://"):
|
|
114
|
+
logger.warning("policy API key is being sent over plaintext HTTP to host=%s", self._host)
|
|
115
|
+
started = time.perf_counter()
|
|
116
|
+
|
|
117
|
+
def check_model(payload, _request_id):
|
|
118
|
+
models = {item["id"] for item in payload.get("data", [])
|
|
119
|
+
if isinstance(item, dict) and isinstance(item.get("id"), str)}
|
|
120
|
+
if self._model not in models:
|
|
121
|
+
raise self._diagnostic("model discovery", "model unavailable", started)
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
self._request("model discovery", "models", http=http, decode=check_model)
|
|
125
|
+
except Exception:
|
|
126
|
+
http.close()
|
|
127
|
+
raise
|
|
128
|
+
self._http = http
|
|
129
|
+
|
|
130
|
+
def close(self) -> None:
|
|
131
|
+
if self._http is not None:
|
|
132
|
+
try:
|
|
133
|
+
self._http.close()
|
|
134
|
+
finally:
|
|
135
|
+
self._http = None
|
|
136
|
+
|
|
137
|
+
def prepare_session(self) -> None:
|
|
138
|
+
"""Prepare locally before the runtime starts its background thread."""
|
|
139
|
+
with self._session_lock:
|
|
140
|
+
if self._state in {"open", "failed", "closing"} or self._inflight:
|
|
141
|
+
raise PolicyUnavailable("policy session is already active")
|
|
142
|
+
self._session_id, self._next_turn = None, 1
|
|
143
|
+
self._pending_messages.clear()
|
|
144
|
+
self._awaiting_execution = None
|
|
145
|
+
self._state = "starting"
|
|
146
|
+
|
|
147
|
+
def begin_session(self) -> None:
|
|
148
|
+
with self._session_lock:
|
|
149
|
+
if self._state in {"open", "failed", "closing"} or self._inflight:
|
|
150
|
+
raise PolicyUnavailable("policy session is already active")
|
|
151
|
+
if self._state == "unknown":
|
|
152
|
+
raise PolicyUnavailable("policy session outcome is unknown; prepare a new run")
|
|
153
|
+
if self._state == "closed":
|
|
154
|
+
self.prepare_session()
|
|
155
|
+
self._inflight = True
|
|
156
|
+
try:
|
|
157
|
+
if not self.ready:
|
|
158
|
+
self.connect()
|
|
159
|
+
(session_id, next_turn), _ = self._request(
|
|
160
|
+
"session create", "sessions", body={"model": self._model},
|
|
161
|
+
decode=lambda payload, _: decode_session(payload),
|
|
162
|
+
)
|
|
163
|
+
with self._session_lock:
|
|
164
|
+
self._session_id, self._next_turn = session_id, next_turn
|
|
165
|
+
self._state = "open"
|
|
166
|
+
except PolicyUnavailable:
|
|
167
|
+
with self._session_lock:
|
|
168
|
+
self._state = "unknown"
|
|
169
|
+
raise
|
|
170
|
+
finally:
|
|
171
|
+
with self._session_lock:
|
|
172
|
+
self._inflight = False
|
|
173
|
+
|
|
174
|
+
def record_lifecycle(self, event: str) -> None:
|
|
175
|
+
if event not in {"pause", "resume"}:
|
|
176
|
+
raise ValueError("session event must be pause or resume")
|
|
177
|
+
with self._session_lock:
|
|
178
|
+
if self._state in {"starting", "open"}:
|
|
179
|
+
self._pending_messages.append({"role": "user", "content": json.dumps({"event": event})})
|
|
180
|
+
|
|
181
|
+
def record_execution(self, chunk: ActionChunk, executed_steps: int, *, completed: bool) -> None:
|
|
182
|
+
if chunk.session_id is None:
|
|
183
|
+
return
|
|
184
|
+
with self._session_lock:
|
|
185
|
+
pending = self._awaiting_execution
|
|
186
|
+
if chunk.session_id != self._session_id or pending is None or chunk.session_turn != pending[0]:
|
|
187
|
+
raise PolicyUnavailable("policy execution feedback does not match the active turn")
|
|
188
|
+
if type(executed_steps) is not int or not 0 <= executed_steps <= len(chunk):
|
|
189
|
+
raise ValueError("executed_steps exceeds the returned action chunk")
|
|
190
|
+
self._pending_messages.extend([
|
|
191
|
+
pending[1],
|
|
192
|
+
{"role": "tool", "tool_call_id": f"call_{chunk.request_id}", "content": json.dumps({
|
|
193
|
+
"executed_steps": executed_steps,
|
|
194
|
+
"status": "completed" if completed and executed_steps == len(chunk) else "interrupted",
|
|
195
|
+
})},
|
|
196
|
+
])
|
|
197
|
+
self._awaiting_execution = None
|
|
198
|
+
|
|
199
|
+
def _check_turn_available(self) -> tuple[str, int]:
|
|
200
|
+
"""Called under the session lock before encoding and before sending."""
|
|
201
|
+
if self._state != "open" or self._inflight:
|
|
202
|
+
raise PolicyUnavailable("policy session is unavailable; end this episode before starting another")
|
|
203
|
+
if self._awaiting_execution is not None:
|
|
204
|
+
raise PolicyUnavailable("policy previous turn has no execution feedback")
|
|
205
|
+
return self._session_id, self._next_turn
|
|
206
|
+
|
|
207
|
+
def infer(self, pose7: np.ndarray, gripper: float, image: np.ndarray, instruction: str) -> ActionChunk:
|
|
208
|
+
with self._session_lock:
|
|
209
|
+
session_id, turn = self._check_turn_available()
|
|
210
|
+
observation = encode_observation(pose7, gripper, image, instruction, self._image_height)
|
|
211
|
+
with self._session_lock:
|
|
212
|
+
if self._check_turn_available() != (session_id, turn):
|
|
213
|
+
raise PolicyUnavailable("policy session changed before the request was sent")
|
|
214
|
+
feedback = list(self._pending_messages)
|
|
215
|
+
if len(feedback) > 64:
|
|
216
|
+
raise PolicyUnavailable("policy session pending messages exceeds the protocol limit")
|
|
217
|
+
request = {"model": self._model, "messages": feedback + [observation],
|
|
218
|
+
"metadata": {"session_id": session_id, "turn": str(turn)}}
|
|
219
|
+
self._inflight = True
|
|
220
|
+
accepted = False
|
|
221
|
+
try:
|
|
222
|
+
if not self.ready:
|
|
223
|
+
self.connect()
|
|
224
|
+
started = time.perf_counter()
|
|
225
|
+
(message, chunk), request_ms = self._request(
|
|
226
|
+
"inference", "chat/completions", body=request,
|
|
227
|
+
decode=lambda payload, request_id: decode_completion(
|
|
228
|
+
payload, request_id, session_id=session_id, turn=turn, instruction=instruction,
|
|
229
|
+
),
|
|
230
|
+
)
|
|
231
|
+
chunk = replace(chunk, infer_ms=(time.perf_counter() - started) * 1e3, request_ms=request_ms)
|
|
232
|
+
with self._session_lock:
|
|
233
|
+
# Events received during the POST belong to the next turn.
|
|
234
|
+
del self._pending_messages[:len(feedback)]
|
|
235
|
+
self._next_turn = turn + 1
|
|
236
|
+
self._awaiting_execution = (turn, message)
|
|
237
|
+
accepted = True
|
|
238
|
+
return chunk
|
|
239
|
+
except PolicyUnavailable:
|
|
240
|
+
self.close()
|
|
241
|
+
raise
|
|
242
|
+
finally:
|
|
243
|
+
with self._session_lock:
|
|
244
|
+
self._inflight = False
|
|
245
|
+
if not accepted:
|
|
246
|
+
self._state = "failed"
|
|
247
|
+
|
|
248
|
+
def end_session(self, reason: str = "stopped") -> None:
|
|
249
|
+
"""Best effort close after local hold, without discovery or retries."""
|
|
250
|
+
with self._session_lock:
|
|
251
|
+
if self._inflight:
|
|
252
|
+
raise PolicyUnavailable("policy session request is still in flight")
|
|
253
|
+
if self._state not in {"open", "failed"}:
|
|
254
|
+
if self._state == "starting":
|
|
255
|
+
self._state = "closed"
|
|
256
|
+
self._pending_messages.clear()
|
|
257
|
+
self._awaiting_execution = None
|
|
258
|
+
return
|
|
259
|
+
session_id = self._session_id
|
|
260
|
+
body = {"reason": (reason.strip() or "stopped")[:80], "messages": list(self._pending_messages)}
|
|
261
|
+
self._state = "closing"
|
|
262
|
+
outcome = "closed"
|
|
263
|
+
try:
|
|
264
|
+
with closing(self._new_http(1.0)) as http:
|
|
265
|
+
self._request(
|
|
266
|
+
"session close", f"sessions/{session_id}/close", body=body, http=http,
|
|
267
|
+
decode=lambda payload, _: decode_session(payload, expected_id=session_id),
|
|
268
|
+
)
|
|
269
|
+
except (PolicyUnavailable, httpx.TransportError, ValueError, TypeError, AttributeError) as exc:
|
|
270
|
+
detail = str(exc) if isinstance(exc, PolicyUnavailable) else f"host={self._host}"
|
|
271
|
+
logger.warning("policy session close was not confirmed: %s", detail)
|
|
272
|
+
outcome = "unknown"
|
|
273
|
+
finally:
|
|
274
|
+
with self._session_lock:
|
|
275
|
+
self._state = outcome
|
|
276
|
+
self._pending_messages.clear()
|
|
277
|
+
self._awaiting_execution = None
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Observation encoding and strict action decoding for the model API contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass, replace
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from .. import geometry as geo
|
|
14
|
+
from ..semantics import ACTION_DT
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resize_to_height(image: np.ndarray, height: int) -> np.ndarray:
|
|
18
|
+
"""Resize to an exact height while preserving aspect ratio; never crop or pad."""
|
|
19
|
+
from PIL import Image
|
|
20
|
+
|
|
21
|
+
if image.ndim != 3 or image.shape[2] != 3:
|
|
22
|
+
raise ValueError(f"expected HxWx3 RGB, got {image.shape}")
|
|
23
|
+
if height < 1:
|
|
24
|
+
raise ValueError(f"resize height must be >= 1, got {height}")
|
|
25
|
+
cur_h, cur_w = image.shape[0], image.shape[1]
|
|
26
|
+
width = max(1, round(cur_w * height / cur_h))
|
|
27
|
+
resized = Image.fromarray(image.astype(np.uint8)).resize((width, height), resample=Image.BILINEAR)
|
|
28
|
+
return np.asarray(resized, dtype=np.uint8)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class ActionChunk:
|
|
33
|
+
"""One inference result: T coordinated arm + gripper frames."""
|
|
34
|
+
|
|
35
|
+
arm: np.ndarray # (T, 7) pose7d wxyz
|
|
36
|
+
gripper: np.ndarray # (T,) normalized [0, 1]
|
|
37
|
+
infer_ms: float
|
|
38
|
+
instruction: str
|
|
39
|
+
request_id: str | None = None
|
|
40
|
+
# Time spent in the HTTP POST, excluding image encoding and response decoding.
|
|
41
|
+
request_ms: float | None = None
|
|
42
|
+
# Model actions belong to a session turn; locally planned motions do not.
|
|
43
|
+
session_id: str | None = None
|
|
44
|
+
session_turn: int | None = None
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def action_dt(self):
|
|
48
|
+
return ACTION_DT
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
if self.arm.ndim != 2 or self.arm.shape[1] != 7:
|
|
52
|
+
raise ValueError(f"chunk arm must be (T,7), got {self.arm.shape}")
|
|
53
|
+
if self.gripper.shape != (self.arm.shape[0],):
|
|
54
|
+
raise ValueError(f"chunk gripper must be (T,), got {self.gripper.shape}")
|
|
55
|
+
|
|
56
|
+
def __len__(self) -> int:
|
|
57
|
+
return int(self.arm.shape[0])
|
|
58
|
+
|
|
59
|
+
def summary(self, current_pose: np.ndarray, current_gripper: float) -> dict:
|
|
60
|
+
"""Summarize endpoint displacement and gripper movement."""
|
|
61
|
+
last = self.arm[-1]
|
|
62
|
+
d_xyz = (last[0:3] - np.asarray(current_pose, dtype=np.float64)[0:3]).round(4).tolist()
|
|
63
|
+
_, d_rot = geo.pose_delta(current_pose, last)
|
|
64
|
+
g_end = float(self.gripper[-1])
|
|
65
|
+
if abs(g_end - current_gripper) < 0.05:
|
|
66
|
+
g_desc = f"{current_gripper:.2f} (holding)"
|
|
67
|
+
else:
|
|
68
|
+
g_desc = f"{current_gripper:.2f} -> {g_end:.2f} ({'opening' if g_end > current_gripper else 'closing'})"
|
|
69
|
+
return {
|
|
70
|
+
"n_steps": len(self),
|
|
71
|
+
"duration_s": round(len(self) * self.action_dt, 3),
|
|
72
|
+
"delta_xyz_m": d_xyz,
|
|
73
|
+
"delta_rot_rad": round(d_rot, 4),
|
|
74
|
+
"gripper": g_desc,
|
|
75
|
+
"infer_ms": round(self.infer_ms, 1),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
OPENAI_GRIPPER_TOLERANCE = 0.01
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def decode_actions(response: dict, infer_ms: float, instruction: str,
|
|
83
|
+
request_id: str | None = None, request_ms: float | None = None) -> ActionChunk:
|
|
84
|
+
"""Decode absolute ``[xyz, quat_xyzw, gripper]`` action rows from the OpenAI adapter."""
|
|
85
|
+
if not isinstance(response, dict) or "output" not in response:
|
|
86
|
+
raise RuntimeError("policy response needs an 'output' array")
|
|
87
|
+
try:
|
|
88
|
+
actions = np.asarray(response["output"], dtype=np.float64)
|
|
89
|
+
except (TypeError, ValueError) as exc:
|
|
90
|
+
raise RuntimeError("policy 'output' must be a rectangular numeric array") from exc
|
|
91
|
+
if actions.ndim != 2 or actions.shape[0] < 1 or actions.shape[1] != 8:
|
|
92
|
+
raise RuntimeError(f"policy 'output' must be (T,8), got {actions.shape}")
|
|
93
|
+
if not np.all(np.isfinite(actions)):
|
|
94
|
+
raise RuntimeError("policy 'output' contains non-finite values")
|
|
95
|
+
try:
|
|
96
|
+
quat_wxyz = geo.quat_xyzw_to_wxyz(geo.normalize_quat(actions[:, 3:7]))
|
|
97
|
+
except ValueError as exc:
|
|
98
|
+
raise RuntimeError("policy 'output' contains a zero-norm quaternion") from exc
|
|
99
|
+
gripper = actions[:, 7]
|
|
100
|
+
tol = OPENAI_GRIPPER_TOLERANCE
|
|
101
|
+
if np.any((gripper < -tol) | (gripper > 1.0 + tol)):
|
|
102
|
+
raise RuntimeError("policy 'output' gripper exceeds the [0,1] range by more than 0.01")
|
|
103
|
+
return ActionChunk(
|
|
104
|
+
arm=np.concatenate([actions[:, 0:3], quat_wxyz], axis=1),
|
|
105
|
+
gripper=np.clip(gripper, 0.0, 1.0),
|
|
106
|
+
infer_ms=infer_ms,
|
|
107
|
+
instruction=instruction,
|
|
108
|
+
request_id=request_id,
|
|
109
|
+
request_ms=request_ms,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def encode_observation(pose7: np.ndarray, gripper: float, image: np.ndarray,
|
|
114
|
+
instruction: str, image_height: int | None) -> dict:
|
|
115
|
+
from PIL import Image
|
|
116
|
+
|
|
117
|
+
if image_height is not None:
|
|
118
|
+
image = resize_to_height(image, image_height)
|
|
119
|
+
image = np.asarray(image, dtype=np.uint8)
|
|
120
|
+
if image.ndim != 3 or image.shape[2] != 3:
|
|
121
|
+
raise ValueError(f"expected HxWx3 RGB, got {image.shape}")
|
|
122
|
+
encoded = io.BytesIO()
|
|
123
|
+
Image.fromarray(image).save(encoded, format="PNG")
|
|
124
|
+
state = np.concatenate([geo.obs_pose(pose7), [float(gripper)]]).astype(np.float32)
|
|
125
|
+
return {
|
|
126
|
+
"role": "user",
|
|
127
|
+
"content": [
|
|
128
|
+
{"type": "text", "text": json.dumps({"instruction": instruction, "state": state.tolist()})},
|
|
129
|
+
{"type": "image_url", "image_url": {
|
|
130
|
+
"url": "data:image/png;base64," + base64.b64encode(encoded.getvalue()).decode("ascii")
|
|
131
|
+
}},
|
|
132
|
+
],
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def decode_session(payload: dict, expected_id: str | None = None) -> tuple[str, int]:
|
|
137
|
+
"""Validate lifecycle responses without retaining the server's history."""
|
|
138
|
+
session_id = payload.get("id")
|
|
139
|
+
next_turn = payload.get("next_turn", 1)
|
|
140
|
+
if (not isinstance(session_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", session_id)
|
|
141
|
+
or (expected_id is not None and session_id != expected_id)
|
|
142
|
+
or type(next_turn) is not int or next_turn < 1
|
|
143
|
+
or not isinstance(payload.get("messages"), list)):
|
|
144
|
+
raise ValueError("invalid session response")
|
|
145
|
+
return session_id, next_turn
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def decode_completion(payload: dict, request_id: str | None, *, session_id: str,
|
|
149
|
+
turn: int, instruction: str) -> tuple[dict, ActionChunk]:
|
|
150
|
+
choice = payload["choices"][0]
|
|
151
|
+
message = choice["message"]
|
|
152
|
+
calls = message["tool_calls"]
|
|
153
|
+
if (message.get("role") != "assistant" or message.get("content") is not None
|
|
154
|
+
or choice.get("finish_reason") != "tool_calls" or not isinstance(calls, list)
|
|
155
|
+
or len(calls) != 1 or calls[0].get("type") != "function"
|
|
156
|
+
or calls[0].get("id") != f"call_{request_id}"
|
|
157
|
+
or calls[0]["function"].get("name") != "execute_actions"):
|
|
158
|
+
raise ValueError("completion must contain one execute_actions tool call")
|
|
159
|
+
metadata = payload.get("metadata", {})
|
|
160
|
+
if (metadata.get("session_id") != session_id or metadata.get("turn") != str(turn)
|
|
161
|
+
or metadata.get("next_turn") != str(turn + 1)
|
|
162
|
+
or not request_id or not re.fullmatch(r"[a-f0-9]{32}", request_id)):
|
|
163
|
+
raise ValueError("completion does not match the requested turn")
|
|
164
|
+
actions = json.loads(calls[0]["function"]["arguments"])
|
|
165
|
+
chunk = decode_actions(actions, 0.0, instruction, request_id)
|
|
166
|
+
return message, replace(chunk, session_id=session_id, session_turn=turn)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Local model endpoint settings and credentials."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
from ..errors import PolicyUnavailable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def validate_policy_endpoint(url: str, model: str) -> tuple[str, str]:
|
|
14
|
+
if not isinstance(url, str) or not url or url != url.strip():
|
|
15
|
+
raise ValueError("base URL is required")
|
|
16
|
+
try:
|
|
17
|
+
parsed = urlsplit(url)
|
|
18
|
+
valid_port = parsed.port is None or 1 <= parsed.port <= 65535
|
|
19
|
+
except ValueError:
|
|
20
|
+
raise ValueError("base URL is invalid") from None
|
|
21
|
+
if (parsed.scheme not in ("http", "https") or not parsed.hostname or
|
|
22
|
+
parsed.username is not None or parsed.password is not None or
|
|
23
|
+
parsed.query or parsed.fragment or
|
|
24
|
+
not valid_port or any(c.isspace() for c in url)):
|
|
25
|
+
raise ValueError("base URL must be an http(s) endpoint without credentials or query")
|
|
26
|
+
if (not isinstance(model, str) or not model or model != model.strip() or
|
|
27
|
+
len(model) > 128 or any(c.isspace() or not c.isprintable() for c in model)):
|
|
28
|
+
raise ValueError("model must be a nonempty ID of at most 128 characters")
|
|
29
|
+
return url.rstrip("/"), model
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _write_private_file(path_text: str, content: str) -> Path:
|
|
33
|
+
path = Path(path_text).expanduser()
|
|
34
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
35
|
+
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
36
|
+
try:
|
|
37
|
+
os.fchmod(fd, 0o600)
|
|
38
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
39
|
+
fd = -1
|
|
40
|
+
stream.write(content)
|
|
41
|
+
stream.flush()
|
|
42
|
+
os.fsync(stream.fileno())
|
|
43
|
+
os.replace(temporary, path)
|
|
44
|
+
except Exception:
|
|
45
|
+
if fd >= 0:
|
|
46
|
+
os.close(fd)
|
|
47
|
+
Path(temporary).unlink(missing_ok=True)
|
|
48
|
+
raise
|
|
49
|
+
return path
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_token(token_file: str) -> str:
|
|
53
|
+
path = Path(token_file).expanduser()
|
|
54
|
+
try:
|
|
55
|
+
token = path.read_text(encoding="utf-8").strip()
|
|
56
|
+
except (OSError, UnicodeError):
|
|
57
|
+
token = ""
|
|
58
|
+
if not token:
|
|
59
|
+
raise PolicyUnavailable(
|
|
60
|
+
f"policy API key is not configured at {path}; use robot_configure_policy()"
|
|
61
|
+
)
|
|
62
|
+
return token
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def policy_token_configured(token_file: str) -> bool:
|
|
66
|
+
try:
|
|
67
|
+
read_token(token_file)
|
|
68
|
+
except PolicyUnavailable:
|
|
69
|
+
return False
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def write_policy_token(token_file: str, token: str) -> Path:
|
|
74
|
+
if "\n" in token or "\r" in token:
|
|
75
|
+
raise ValueError("policy API key must be a single line")
|
|
76
|
+
token = token.strip()
|
|
77
|
+
if not token:
|
|
78
|
+
raise ValueError("policy API key must not be empty")
|
|
79
|
+
return _write_private_file(token_file, token + "\n")
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"brand": { "zh": "破壳机器人", "en": "PokeBot" },
|
|
3
|
+
"product": { "name": "RoboAPI", "namespace": "roboapi" },
|
|
4
|
+
"labels": {
|
|
5
|
+
"docs": { "zh": "{product} 文档", "en": "{product} Docs" },
|
|
6
|
+
"docsTitle": { "zh": "{product} 开发者文档", "en": "{product} Documentation" },
|
|
7
|
+
"console": { "zh": "{product} 控制台", "en": "{product} Console" },
|
|
8
|
+
"cloudApi": { "zh": "云端模型 API", "en": "Cloud Model API" },
|
|
9
|
+
"cloudConsole": { "zh": "云端控制台", "en": "Cloud Console" },
|
|
10
|
+
"assistant": { "zh": "文档助手", "en": "Docs Assistant" }
|
|
11
|
+
}
|
|
12
|
+
}
|