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.
Files changed (94) hide show
  1. roboapi/__init__.py +2 -0
  2. roboapi/calibration/__init__.py +6 -0
  3. roboapi/calibration/profile.py +139 -0
  4. roboapi/calibration/session.py +350 -0
  5. roboapi/cli.py +215 -0
  6. roboapi/config/__init__.py +22 -0
  7. roboapi/config/loader.py +119 -0
  8. roboapi/config/models.py +191 -0
  9. roboapi/embodiment.py +251 -0
  10. roboapi/errors.py +37 -0
  11. roboapi/geometry.py +337 -0
  12. roboapi/hardware/__init__.py +5 -0
  13. roboapi/hardware/base.py +113 -0
  14. roboapi/hardware/camera.py +167 -0
  15. roboapi/hardware/franka/__init__.py +5 -0
  16. roboapi/hardware/franka/arm.py +476 -0
  17. roboapi/hardware/franka/desk.py +555 -0
  18. roboapi/hardware/franka/feedback.py +196 -0
  19. roboapi/hardware/franka/guiding.py +104 -0
  20. roboapi/hardware/franka/ik.py +543 -0
  21. roboapi/hardware/franka/limits.py +110 -0
  22. roboapi/hardware/franka/settings.py +116 -0
  23. roboapi/hardware/robotiq.py +350 -0
  24. roboapi/hardware/ur/__init__.py +16 -0
  25. roboapi/hardware/ur/build.py +92 -0
  26. roboapi/hardware/ur/compliant.py +128 -0
  27. roboapi/hardware/ur/dashboard.py +103 -0
  28. roboapi/hardware/ur/feedback.py +159 -0
  29. roboapi/hardware/ur/ipc.py +206 -0
  30. roboapi/hardware/ur/native/CMakeLists.txt +37 -0
  31. roboapi/hardware/ur/native/dependencies.lock.json +13 -0
  32. roboapi/hardware/ur/native/include/ur_cpp/cartesian_speedl.hpp +170 -0
  33. roboapi/hardware/ur/native/include/ur_cpp/control_loop.hpp +126 -0
  34. roboapi/hardware/ur/native/include/ur_cpp/geom.hpp +72 -0
  35. roboapi/hardware/ur/native/include/ur_cpp/motion_primitives.hpp +350 -0
  36. roboapi/hardware/ur/native/include/ur_cpp/robot_io.hpp +26 -0
  37. roboapi/hardware/ur/native/include/ur_cpp/streaming_pose.hpp +369 -0
  38. roboapi/hardware/ur/native/src/control_loop.cpp +512 -0
  39. roboapi/hardware/ur/native/src/robot_io_urcl.cpp +143 -0
  40. roboapi/hardware/ur/native/tests/cartesian_speedl_unit.cpp +113 -0
  41. roboapi/hardware/ur/native/tests/control_modes_unit.cpp +137 -0
  42. roboapi/hardware/ur/native/tests/streaming_pose_unit.cpp +348 -0
  43. roboapi/hardware/ur/native/tests/velocity_governor_unit.cpp +65 -0
  44. roboapi/hardware/ur/native/worker.cpp +147 -0
  45. roboapi/hardware/ur/position.py +253 -0
  46. roboapi/hardware/ur/settings.py +95 -0
  47. roboapi/images.py +16 -0
  48. roboapi/initialize.py +137 -0
  49. roboapi/mcp.py +497 -0
  50. roboapi/motion.py +125 -0
  51. roboapi/naming.py +7 -0
  52. roboapi/operations.py +45 -0
  53. roboapi/policy/__init__.py +7 -0
  54. roboapi/policy/client.py +277 -0
  55. roboapi/policy/protocol.py +166 -0
  56. roboapi/policy/settings.py +79 -0
  57. roboapi/product-names.json +12 -0
  58. roboapi/records.py +149 -0
  59. roboapi/runtime.py +911 -0
  60. roboapi/semantics.py +6 -0
  61. roboapi/tasks.py +337 -0
  62. roboapi/web/__init__.py +5 -0
  63. roboapi/web/presets.py +44 -0
  64. roboapi/web/server.py +419 -0
  65. roboapi/web/static/assets/Bundler-LICENSE.txt +48 -0
  66. roboapi/web/static/assets/CalibrationScene-BO_spRoL.js +4220 -0
  67. roboapi/web/static/assets/Inter-OFL.txt +93 -0
  68. roboapi/web/static/assets/Inter-Variable-c8O0ljhh.ttf +0 -0
  69. roboapi/web/static/assets/Lucide-LICENSE.txt +43 -0
  70. roboapi/web/static/assets/Meshoptimizer-LICENSE.txt +23 -0
  71. roboapi/web/static/assets/MiSans-Regular-ES4DrbaF.woff2 +0 -0
  72. roboapi/web/static/assets/MiSans-Semibold-B34HIk9C.woff2 +0 -0
  73. roboapi/web/static/assets/PokeBrand-NOTICE.txt +20 -0
  74. roboapi/web/static/assets/React-LICENSE.txt +23 -0
  75. roboapi/web/static/assets/Three-LICENSE.txt +23 -0
  76. roboapi/web/static/assets/home-reference-CTpf8-oV.png +0 -0
  77. roboapi/web/static/assets/index-CGLhpUm0.js +28 -0
  78. roboapi/web/static/assets/index-QXqV-fDC.css +1 -0
  79. roboapi/web/static/assets/robotiq-ace-pro-2-EJuoOXTy.glb +0 -0
  80. roboapi/web/static/index.html +15 -0
  81. roboapi-0.4.2.dist-info/METADATA +205 -0
  82. roboapi-0.4.2.dist-info/RECORD +94 -0
  83. roboapi-0.4.2.dist-info/WHEEL +5 -0
  84. roboapi-0.4.2.dist-info/entry_points.txt +2 -0
  85. roboapi-0.4.2.dist-info/licenses/LICENSE +202 -0
  86. roboapi-0.4.2.dist-info/licenses/NOTICE +16 -0
  87. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Bundler-LICENSE.txt +48 -0
  88. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Inter-OFL.txt +93 -0
  89. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Lucide-LICENSE.txt +43 -0
  90. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Meshoptimizer-LICENSE.txt +23 -0
  91. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/PokeBrand-NOTICE.txt +20 -0
  92. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/React-LICENSE.txt +23 -0
  93. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Three-LICENSE.txt +23 -0
  94. 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"]
@@ -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
+ }