foodforthought-cli 0.2.7__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ate/__init__.py +6 -0
- ate/__main__.py +16 -0
- ate/auth/__init__.py +1 -0
- ate/auth/device_flow.py +141 -0
- ate/auth/token_store.py +96 -0
- ate/behaviors/__init__.py +100 -0
- ate/behaviors/approach.py +399 -0
- ate/behaviors/common.py +686 -0
- ate/behaviors/tree.py +454 -0
- ate/cli.py +855 -3995
- ate/client.py +90 -0
- ate/commands/__init__.py +168 -0
- ate/commands/auth.py +389 -0
- ate/commands/bridge.py +448 -0
- ate/commands/data.py +185 -0
- ate/commands/deps.py +111 -0
- ate/commands/generate.py +384 -0
- ate/commands/memory.py +907 -0
- ate/commands/parts.py +166 -0
- ate/commands/primitive.py +399 -0
- ate/commands/protocol.py +288 -0
- ate/commands/recording.py +524 -0
- ate/commands/repo.py +154 -0
- ate/commands/simulation.py +291 -0
- ate/commands/skill.py +303 -0
- ate/commands/skills.py +487 -0
- ate/commands/team.py +147 -0
- ate/commands/workflow.py +271 -0
- ate/detection/__init__.py +38 -0
- ate/detection/base.py +142 -0
- ate/detection/color_detector.py +399 -0
- ate/detection/trash_detector.py +322 -0
- ate/drivers/__init__.py +39 -0
- ate/drivers/ble_transport.py +405 -0
- ate/drivers/mechdog.py +942 -0
- ate/drivers/wifi_camera.py +477 -0
- ate/interfaces/__init__.py +187 -0
- ate/interfaces/base.py +273 -0
- ate/interfaces/body.py +267 -0
- ate/interfaces/detection.py +282 -0
- ate/interfaces/locomotion.py +422 -0
- ate/interfaces/manipulation.py +408 -0
- ate/interfaces/navigation.py +389 -0
- ate/interfaces/perception.py +362 -0
- ate/interfaces/sensors.py +247 -0
- ate/interfaces/types.py +371 -0
- ate/llm_proxy.py +239 -0
- ate/mcp_server.py +387 -0
- ate/memory/__init__.py +35 -0
- ate/memory/cloud.py +244 -0
- ate/memory/context.py +269 -0
- ate/memory/embeddings.py +184 -0
- ate/memory/export.py +26 -0
- ate/memory/merge.py +146 -0
- ate/memory/migrate/__init__.py +34 -0
- ate/memory/migrate/base.py +89 -0
- ate/memory/migrate/pipeline.py +189 -0
- ate/memory/migrate/sources/__init__.py +13 -0
- ate/memory/migrate/sources/chroma.py +170 -0
- ate/memory/migrate/sources/pinecone.py +120 -0
- ate/memory/migrate/sources/qdrant.py +110 -0
- ate/memory/migrate/sources/weaviate.py +160 -0
- ate/memory/reranker.py +353 -0
- ate/memory/search.py +26 -0
- ate/memory/store.py +548 -0
- ate/recording/__init__.py +83 -0
- ate/recording/demonstration.py +378 -0
- ate/recording/session.py +415 -0
- ate/recording/upload.py +304 -0
- ate/recording/visual.py +416 -0
- ate/recording/wrapper.py +95 -0
- ate/robot/__init__.py +221 -0
- ate/robot/agentic_servo.py +856 -0
- ate/robot/behaviors.py +493 -0
- ate/robot/ble_capture.py +1000 -0
- ate/robot/ble_enumerate.py +506 -0
- ate/robot/calibration.py +668 -0
- ate/robot/calibration_state.py +388 -0
- ate/robot/commands.py +3735 -0
- ate/robot/direction_calibration.py +554 -0
- ate/robot/discovery.py +441 -0
- ate/robot/introspection.py +330 -0
- ate/robot/llm_system_id.py +654 -0
- ate/robot/locomotion_calibration.py +508 -0
- ate/robot/manager.py +270 -0
- ate/robot/marker_generator.py +611 -0
- ate/robot/perception.py +502 -0
- ate/robot/primitives.py +614 -0
- ate/robot/profiles.py +281 -0
- ate/robot/registry.py +322 -0
- ate/robot/servo_mapper.py +1153 -0
- ate/robot/skill_upload.py +675 -0
- ate/robot/target_calibration.py +500 -0
- ate/robot/teach.py +515 -0
- ate/robot/types.py +242 -0
- ate/robot/visual_labeler.py +1048 -0
- ate/robot/visual_servo_loop.py +494 -0
- ate/robot/visual_servoing.py +570 -0
- ate/robot/visual_system_id.py +906 -0
- ate/transports/__init__.py +121 -0
- ate/transports/base.py +394 -0
- ate/transports/ble.py +405 -0
- ate/transports/hybrid.py +444 -0
- ate/transports/serial.py +345 -0
- ate/urdf/__init__.py +30 -0
- ate/urdf/capture.py +582 -0
- ate/urdf/cloud.py +491 -0
- ate/urdf/collision.py +271 -0
- ate/urdf/commands.py +708 -0
- ate/urdf/depth.py +360 -0
- ate/urdf/inertial.py +312 -0
- ate/urdf/kinematics.py +330 -0
- ate/urdf/lifting.py +415 -0
- ate/urdf/meshing.py +300 -0
- ate/urdf/models/__init__.py +110 -0
- ate/urdf/models/depth_anything.py +253 -0
- ate/urdf/models/sam2.py +324 -0
- ate/urdf/motion_analysis.py +396 -0
- ate/urdf/pipeline.py +468 -0
- ate/urdf/scale.py +256 -0
- ate/urdf/scan_session.py +411 -0
- ate/urdf/segmentation.py +299 -0
- ate/urdf/synthesis.py +319 -0
- ate/urdf/topology.py +336 -0
- ate/urdf/validation.py +371 -0
- {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/METADATA +9 -1
- foodforthought_cli-0.3.0.dist-info/RECORD +166 -0
- {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/WHEEL +1 -1
- foodforthought_cli-0.2.7.dist-info/RECORD +0 -44
- {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/entry_points.txt +0 -0
- {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/top_level.txt +0 -0
ate/interfaces/types.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Common data types for robot interfaces.
|
|
3
|
+
|
|
4
|
+
All units are SI:
|
|
5
|
+
- Distance: meters (m)
|
|
6
|
+
- Angle: radians (rad)
|
|
7
|
+
- Time: seconds (s)
|
|
8
|
+
- Force: Newtons (N)
|
|
9
|
+
- Torque: Newton-meters (Nm)
|
|
10
|
+
- Velocity: m/s or rad/s
|
|
11
|
+
- Acceleration: m/s² or rad/s²
|
|
12
|
+
|
|
13
|
+
These types are designed to be:
|
|
14
|
+
1. Serializable (for telemetry recording)
|
|
15
|
+
2. Immutable (for safety)
|
|
16
|
+
3. Hardware-agnostic (no servo IDs, raw values, etc.)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from enum import Enum, auto
|
|
21
|
+
from typing import List, Optional, Tuple
|
|
22
|
+
import math
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# =============================================================================
|
|
26
|
+
# Spatial Types
|
|
27
|
+
# =============================================================================
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Vector3:
|
|
31
|
+
"""3D vector in meters (for position) or m/s (for velocity)."""
|
|
32
|
+
x: float = 0.0
|
|
33
|
+
y: float = 0.0
|
|
34
|
+
z: float = 0.0
|
|
35
|
+
|
|
36
|
+
def __add__(self, other: "Vector3") -> "Vector3":
|
|
37
|
+
return Vector3(self.x + other.x, self.y + other.y, self.z + other.z)
|
|
38
|
+
|
|
39
|
+
def __sub__(self, other: "Vector3") -> "Vector3":
|
|
40
|
+
return Vector3(self.x - other.x, self.y - other.y, self.z - other.z)
|
|
41
|
+
|
|
42
|
+
def __mul__(self, scalar: float) -> "Vector3":
|
|
43
|
+
return Vector3(self.x * scalar, self.y * scalar, self.z * scalar)
|
|
44
|
+
|
|
45
|
+
def magnitude(self) -> float:
|
|
46
|
+
return math.sqrt(self.x**2 + self.y**2 + self.z**2)
|
|
47
|
+
|
|
48
|
+
def normalized(self) -> "Vector3":
|
|
49
|
+
mag = self.magnitude()
|
|
50
|
+
if mag == 0:
|
|
51
|
+
return Vector3(0, 0, 0)
|
|
52
|
+
return Vector3(self.x / mag, self.y / mag, self.z / mag)
|
|
53
|
+
|
|
54
|
+
def to_tuple(self) -> Tuple[float, float, float]:
|
|
55
|
+
return (self.x, self.y, self.z)
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_tuple(cls, t: Tuple[float, float, float]) -> "Vector3":
|
|
59
|
+
return cls(t[0], t[1], t[2])
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def zero(cls) -> "Vector3":
|
|
63
|
+
return cls(0.0, 0.0, 0.0)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def forward(cls) -> "Vector3":
|
|
67
|
+
"""Unit vector in forward direction (+X in robot frame)."""
|
|
68
|
+
return cls(1.0, 0.0, 0.0)
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def up(cls) -> "Vector3":
|
|
72
|
+
"""Unit vector in up direction (+Z in robot frame)."""
|
|
73
|
+
return cls(0.0, 0.0, 1.0)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class Quaternion:
|
|
78
|
+
"""Rotation as quaternion (w, x, y, z). Normalized."""
|
|
79
|
+
w: float = 1.0
|
|
80
|
+
x: float = 0.0
|
|
81
|
+
y: float = 0.0
|
|
82
|
+
z: float = 0.0
|
|
83
|
+
|
|
84
|
+
def to_euler(self) -> Tuple[float, float, float]:
|
|
85
|
+
"""Convert to Euler angles (roll, pitch, yaw) in radians."""
|
|
86
|
+
# Roll (x-axis rotation)
|
|
87
|
+
sinr_cosp = 2 * (self.w * self.x + self.y * self.z)
|
|
88
|
+
cosr_cosp = 1 - 2 * (self.x * self.x + self.y * self.y)
|
|
89
|
+
roll = math.atan2(sinr_cosp, cosr_cosp)
|
|
90
|
+
|
|
91
|
+
# Pitch (y-axis rotation)
|
|
92
|
+
sinp = 2 * (self.w * self.y - self.z * self.x)
|
|
93
|
+
if abs(sinp) >= 1:
|
|
94
|
+
pitch = math.copysign(math.pi / 2, sinp)
|
|
95
|
+
else:
|
|
96
|
+
pitch = math.asin(sinp)
|
|
97
|
+
|
|
98
|
+
# Yaw (z-axis rotation)
|
|
99
|
+
siny_cosp = 2 * (self.w * self.z + self.x * self.y)
|
|
100
|
+
cosy_cosp = 1 - 2 * (self.y * self.y + self.z * self.z)
|
|
101
|
+
yaw = math.atan2(siny_cosp, cosy_cosp)
|
|
102
|
+
|
|
103
|
+
return (roll, pitch, yaw)
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def from_euler(cls, roll: float, pitch: float, yaw: float) -> "Quaternion":
|
|
107
|
+
"""Create from Euler angles (roll, pitch, yaw) in radians."""
|
|
108
|
+
cr, cp, cy = math.cos(roll/2), math.cos(pitch/2), math.cos(yaw/2)
|
|
109
|
+
sr, sp, sy = math.sin(roll/2), math.sin(pitch/2), math.sin(yaw/2)
|
|
110
|
+
|
|
111
|
+
return cls(
|
|
112
|
+
w=cr * cp * cy + sr * sp * sy,
|
|
113
|
+
x=sr * cp * cy - cr * sp * sy,
|
|
114
|
+
y=cr * sp * cy + sr * cp * sy,
|
|
115
|
+
z=cr * cp * sy - sr * sp * cy,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def identity(cls) -> "Quaternion":
|
|
120
|
+
return cls(1.0, 0.0, 0.0, 0.0)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True)
|
|
124
|
+
class Pose:
|
|
125
|
+
"""6DOF pose: position + orientation."""
|
|
126
|
+
position: Vector3 = field(default_factory=Vector3.zero)
|
|
127
|
+
orientation: Quaternion = field(default_factory=Quaternion.identity)
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def identity(cls) -> "Pose":
|
|
131
|
+
return cls(Vector3.zero(), Quaternion.identity())
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class Twist:
|
|
136
|
+
"""Velocity in 6DOF: linear + angular velocity."""
|
|
137
|
+
linear: Vector3 = field(default_factory=Vector3.zero) # m/s
|
|
138
|
+
angular: Vector3 = field(default_factory=Vector3.zero) # rad/s
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def zero(cls) -> "Twist":
|
|
142
|
+
return cls(Vector3.zero(), Vector3.zero())
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# =============================================================================
|
|
146
|
+
# Joint/Motor Types
|
|
147
|
+
# =============================================================================
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class JointState:
|
|
151
|
+
"""State of all joints. Lists are ordered by joint index."""
|
|
152
|
+
positions: Tuple[float, ...] = () # radians
|
|
153
|
+
velocities: Tuple[float, ...] = () # rad/s
|
|
154
|
+
efforts: Tuple[float, ...] = () # Nm (torque)
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def num_joints(self) -> int:
|
|
158
|
+
return len(self.positions)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True)
|
|
162
|
+
class JointLimits:
|
|
163
|
+
"""Limits for a single joint."""
|
|
164
|
+
position_min: float # radians
|
|
165
|
+
position_max: float # radians
|
|
166
|
+
velocity_max: float # rad/s
|
|
167
|
+
effort_max: float # Nm
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# =============================================================================
|
|
171
|
+
# Perception Types
|
|
172
|
+
# =============================================================================
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class Image:
|
|
176
|
+
"""RGB image from camera."""
|
|
177
|
+
data: bytes
|
|
178
|
+
width: int
|
|
179
|
+
height: int
|
|
180
|
+
encoding: str = "rgb8" # rgb8, bgr8, mono8, jpeg, png
|
|
181
|
+
timestamp: float = 0.0 # seconds since epoch
|
|
182
|
+
frame_id: str = "camera" # coordinate frame
|
|
183
|
+
|
|
184
|
+
def to_numpy(self):
|
|
185
|
+
"""Convert to numpy array. Requires numpy."""
|
|
186
|
+
import numpy as np
|
|
187
|
+
if self.encoding in ("rgb8", "bgr8"):
|
|
188
|
+
return np.frombuffer(self.data, dtype=np.uint8).reshape(
|
|
189
|
+
(self.height, self.width, 3)
|
|
190
|
+
)
|
|
191
|
+
elif self.encoding == "mono8":
|
|
192
|
+
return np.frombuffer(self.data, dtype=np.uint8).reshape(
|
|
193
|
+
(self.height, self.width)
|
|
194
|
+
)
|
|
195
|
+
else:
|
|
196
|
+
raise ValueError(f"Cannot convert encoding {self.encoding} to numpy")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass
|
|
200
|
+
class DepthImage:
|
|
201
|
+
"""Depth image from depth camera."""
|
|
202
|
+
data: bytes
|
|
203
|
+
width: int
|
|
204
|
+
height: int
|
|
205
|
+
encoding: str = "32FC1" # 32FC1 (float meters), 16UC1 (uint16 mm)
|
|
206
|
+
timestamp: float = 0.0
|
|
207
|
+
frame_id: str = "depth_camera"
|
|
208
|
+
min_range: float = 0.1 # meters
|
|
209
|
+
max_range: float = 10.0 # meters
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass
|
|
213
|
+
class PointCloud:
|
|
214
|
+
"""3D point cloud from lidar or depth camera."""
|
|
215
|
+
points: List[Vector3] # List of 3D points in sensor frame
|
|
216
|
+
intensities: Optional[List[float]] = None
|
|
217
|
+
colors: Optional[List[Tuple[int, int, int]]] = None # RGB
|
|
218
|
+
timestamp: float = 0.0
|
|
219
|
+
frame_id: str = "lidar"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass(frozen=True)
|
|
223
|
+
class IMUReading:
|
|
224
|
+
"""Reading from Inertial Measurement Unit."""
|
|
225
|
+
orientation: Quaternion
|
|
226
|
+
angular_velocity: Vector3 # rad/s
|
|
227
|
+
linear_acceleration: Vector3 # m/s²
|
|
228
|
+
timestamp: float = 0.0
|
|
229
|
+
|
|
230
|
+
# Covariance matrices (optional, for Kalman filtering)
|
|
231
|
+
orientation_covariance: Optional[Tuple[float, ...]] = None
|
|
232
|
+
angular_velocity_covariance: Optional[Tuple[float, ...]] = None
|
|
233
|
+
linear_acceleration_covariance: Optional[Tuple[float, ...]] = None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@dataclass(frozen=True)
|
|
237
|
+
class ForceTorqueReading:
|
|
238
|
+
"""Reading from force-torque sensor."""
|
|
239
|
+
force: Vector3 # Newtons
|
|
240
|
+
torque: Vector3 # Nm
|
|
241
|
+
timestamp: float = 0.0
|
|
242
|
+
frame_id: str = "ft_sensor"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# =============================================================================
|
|
246
|
+
# Robot State Types
|
|
247
|
+
# =============================================================================
|
|
248
|
+
|
|
249
|
+
@dataclass(frozen=True)
|
|
250
|
+
class BatteryState:
|
|
251
|
+
"""Battery status."""
|
|
252
|
+
percentage: float # 0.0 to 1.0
|
|
253
|
+
voltage: float # Volts
|
|
254
|
+
current: float = 0.0 # Amps (positive = discharging)
|
|
255
|
+
is_charging: bool = False
|
|
256
|
+
time_remaining: Optional[float] = None # seconds
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class RobotMode(Enum):
|
|
260
|
+
"""Operating mode of the robot."""
|
|
261
|
+
IDLE = auto()
|
|
262
|
+
READY = auto()
|
|
263
|
+
MOVING = auto()
|
|
264
|
+
ESTOPPED = auto()
|
|
265
|
+
FAULT = auto()
|
|
266
|
+
CHARGING = auto()
|
|
267
|
+
CALIBRATING = auto()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@dataclass
|
|
271
|
+
class RobotStatus:
|
|
272
|
+
"""Overall robot status."""
|
|
273
|
+
mode: RobotMode = RobotMode.IDLE
|
|
274
|
+
is_ready: bool = False
|
|
275
|
+
is_moving: bool = False
|
|
276
|
+
errors: List[str] = field(default_factory=list)
|
|
277
|
+
warnings: List[str] = field(default_factory=list)
|
|
278
|
+
battery: Optional[BatteryState] = None
|
|
279
|
+
uptime: float = 0.0 # seconds since boot
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# =============================================================================
|
|
283
|
+
# Locomotion Types
|
|
284
|
+
# =============================================================================
|
|
285
|
+
|
|
286
|
+
class GaitType(Enum):
|
|
287
|
+
"""Gait patterns for legged robots."""
|
|
288
|
+
# Quadruped gaits
|
|
289
|
+
STAND = auto()
|
|
290
|
+
WALK = auto()
|
|
291
|
+
TROT = auto()
|
|
292
|
+
PACE = auto()
|
|
293
|
+
BOUND = auto()
|
|
294
|
+
GALLOP = auto()
|
|
295
|
+
CRAWL = auto()
|
|
296
|
+
|
|
297
|
+
# Biped gaits
|
|
298
|
+
BIPED_WALK = auto()
|
|
299
|
+
BIPED_RUN = auto()
|
|
300
|
+
|
|
301
|
+
# Special
|
|
302
|
+
CUSTOM = auto()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@dataclass(frozen=True)
|
|
306
|
+
class GaitParameters:
|
|
307
|
+
"""Parameters for gait control."""
|
|
308
|
+
gait_type: GaitType = GaitType.WALK
|
|
309
|
+
stride_length: float = 0.1 # meters
|
|
310
|
+
step_height: float = 0.05 # meters
|
|
311
|
+
cycle_time: float = 0.5 # seconds per gait cycle
|
|
312
|
+
duty_factor: float = 0.6 # fraction of cycle foot is on ground
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# =============================================================================
|
|
316
|
+
# Manipulation Types
|
|
317
|
+
# =============================================================================
|
|
318
|
+
|
|
319
|
+
class GripperState(Enum):
|
|
320
|
+
"""State of a gripper."""
|
|
321
|
+
OPEN = auto()
|
|
322
|
+
CLOSED = auto()
|
|
323
|
+
HOLDING = auto() # Closed and holding an object
|
|
324
|
+
MOVING = auto()
|
|
325
|
+
FAULT = auto()
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@dataclass(frozen=True)
|
|
329
|
+
class GripperStatus:
|
|
330
|
+
"""Detailed gripper status."""
|
|
331
|
+
state: GripperState
|
|
332
|
+
position: float = 1.0 # 0.0 = closed, 1.0 = fully open
|
|
333
|
+
force: float = 0.0 # Newtons (gripping force)
|
|
334
|
+
object_detected: bool = False
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# =============================================================================
|
|
338
|
+
# Coordinate Frames
|
|
339
|
+
# =============================================================================
|
|
340
|
+
|
|
341
|
+
class Frame(Enum):
|
|
342
|
+
"""Standard coordinate frames."""
|
|
343
|
+
WORLD = "world" # Global/map frame
|
|
344
|
+
ODOM = "odom" # Odometry frame
|
|
345
|
+
BASE = "base_link" # Robot base
|
|
346
|
+
BODY = "body" # Robot body center
|
|
347
|
+
CAMERA = "camera" # Camera optical frame
|
|
348
|
+
GRIPPER = "gripper" # End effector frame
|
|
349
|
+
IMU = "imu" # IMU sensor frame
|
|
350
|
+
LIDAR = "lidar" # Lidar sensor frame
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# =============================================================================
|
|
354
|
+
# Action Types (for skill definitions)
|
|
355
|
+
# =============================================================================
|
|
356
|
+
|
|
357
|
+
@dataclass
|
|
358
|
+
class ActionResult:
|
|
359
|
+
"""Result of an action/command."""
|
|
360
|
+
success: bool
|
|
361
|
+
message: str = ""
|
|
362
|
+
error_code: Optional[int] = None
|
|
363
|
+
duration: float = 0.0 # How long the action took
|
|
364
|
+
|
|
365
|
+
@classmethod
|
|
366
|
+
def ok(cls, message: str = "Success", duration: float = 0.0) -> "ActionResult":
|
|
367
|
+
return cls(success=True, message=message, duration=duration)
|
|
368
|
+
|
|
369
|
+
@classmethod
|
|
370
|
+
def error(cls, message: str, code: int = -1) -> "ActionResult":
|
|
371
|
+
return cls(success=False, message=message, error_code=code)
|
ate/llm_proxy.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM Proxy - Routes AI requests through FoodforThought edge function.
|
|
3
|
+
|
|
4
|
+
Benefits:
|
|
5
|
+
- No API keys needed on client
|
|
6
|
+
- Automatic usage metering per user
|
|
7
|
+
- Rate limiting (50/week free, 500/week pro)
|
|
8
|
+
- Billing integration via usage_logs table
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
from ate.llm_proxy import LLMProxy
|
|
12
|
+
|
|
13
|
+
proxy = LLMProxy()
|
|
14
|
+
response = proxy.chat(
|
|
15
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
16
|
+
model="claude-3-5-haiku-20241022",
|
|
17
|
+
max_tokens=150,
|
|
18
|
+
)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Dict, Any, List, Optional
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
import requests
|
|
28
|
+
HAS_REQUESTS = True
|
|
29
|
+
except ImportError:
|
|
30
|
+
HAS_REQUESTS = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Configuration
|
|
34
|
+
EDGE_FUNCTION_URL = "https://tbkczrruqxopscwqxntr.supabase.co/functions/v1/chat-proxy"
|
|
35
|
+
CONFIG_DIR = Path.home() / ".ate"
|
|
36
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class LLMResponse:
|
|
41
|
+
"""Parsed response from LLM."""
|
|
42
|
+
content: str
|
|
43
|
+
input_tokens: int
|
|
44
|
+
output_tokens: int
|
|
45
|
+
model: str
|
|
46
|
+
stop_reason: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LLMProxyError(Exception):
|
|
50
|
+
"""Error from LLM proxy."""
|
|
51
|
+
def __init__(self, message: str, status_code: Optional[int] = None, details: Optional[str] = None):
|
|
52
|
+
super().__init__(message)
|
|
53
|
+
self.status_code = status_code
|
|
54
|
+
self.details = details
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class LLMProxy:
|
|
58
|
+
"""
|
|
59
|
+
Proxy for LLM requests through FoodforThought edge function.
|
|
60
|
+
|
|
61
|
+
Handles authentication, rate limiting, and usage tracking automatically.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, access_token: Optional[str] = None):
|
|
65
|
+
"""
|
|
66
|
+
Initialize proxy.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
access_token: Optional auth token. If not provided, reads from CLI config.
|
|
70
|
+
"""
|
|
71
|
+
if not HAS_REQUESTS:
|
|
72
|
+
raise ImportError("requests module required. Install with: pip install requests")
|
|
73
|
+
|
|
74
|
+
self.access_token = access_token or self._load_token()
|
|
75
|
+
self.base_url = EDGE_FUNCTION_URL
|
|
76
|
+
|
|
77
|
+
def _load_token(self) -> Optional[str]:
|
|
78
|
+
"""Load access token from CLI config file."""
|
|
79
|
+
if not CONFIG_FILE.exists():
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
with open(CONFIG_FILE) as f:
|
|
84
|
+
config = json.load(f)
|
|
85
|
+
return config.get("access_token")
|
|
86
|
+
except Exception:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
def chat(
|
|
90
|
+
self,
|
|
91
|
+
messages: List[Dict[str, Any]],
|
|
92
|
+
model: str = "claude-3-5-haiku-20241022",
|
|
93
|
+
max_tokens: int = 1024,
|
|
94
|
+
temperature: float = 0.7,
|
|
95
|
+
system: Optional[str] = None,
|
|
96
|
+
stream: bool = False,
|
|
97
|
+
) -> LLMResponse:
|
|
98
|
+
"""
|
|
99
|
+
Send chat completion request through edge function.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
messages: List of message dicts with 'role' and 'content'
|
|
103
|
+
model: Model to use (default: claude-3-5-haiku-20241022)
|
|
104
|
+
max_tokens: Maximum tokens in response
|
|
105
|
+
temperature: Sampling temperature
|
|
106
|
+
system: Optional system prompt
|
|
107
|
+
stream: Whether to stream (not yet supported)
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
LLMResponse with content and token usage
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
LLMProxyError: On API errors or auth issues
|
|
114
|
+
"""
|
|
115
|
+
if stream:
|
|
116
|
+
raise NotImplementedError("Streaming not yet implemented in proxy")
|
|
117
|
+
|
|
118
|
+
# Build request
|
|
119
|
+
all_messages = []
|
|
120
|
+
if system:
|
|
121
|
+
all_messages.append({"role": "system", "content": system})
|
|
122
|
+
all_messages.extend(messages)
|
|
123
|
+
|
|
124
|
+
payload = {
|
|
125
|
+
"messages": all_messages,
|
|
126
|
+
"model": model,
|
|
127
|
+
"max_tokens": max_tokens,
|
|
128
|
+
"temperature": temperature,
|
|
129
|
+
"stream": False,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
headers = {
|
|
133
|
+
"Content-Type": "application/json",
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
# Add auth header if we have a token
|
|
137
|
+
if self.access_token:
|
|
138
|
+
headers["Authorization"] = f"Bearer {self.access_token}"
|
|
139
|
+
else:
|
|
140
|
+
raise LLMProxyError(
|
|
141
|
+
"Not authenticated. Run 'ate login' first.",
|
|
142
|
+
status_code=401,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Make request
|
|
146
|
+
try:
|
|
147
|
+
response = requests.post(
|
|
148
|
+
self.base_url,
|
|
149
|
+
json=payload,
|
|
150
|
+
headers=headers,
|
|
151
|
+
timeout=60,
|
|
152
|
+
)
|
|
153
|
+
except requests.RequestException as e:
|
|
154
|
+
raise LLMProxyError(f"Request failed: {e}")
|
|
155
|
+
|
|
156
|
+
# Parse response
|
|
157
|
+
if response.status_code == 401:
|
|
158
|
+
raise LLMProxyError(
|
|
159
|
+
"Authentication failed. Try 'ate login' to re-authenticate.",
|
|
160
|
+
status_code=401,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if response.status_code == 402:
|
|
164
|
+
# Rate limit exceeded
|
|
165
|
+
data = response.json()
|
|
166
|
+
usage = data.get("usage", {})
|
|
167
|
+
raise LLMProxyError(
|
|
168
|
+
f"Rate limit exceeded: {usage.get('current', '?')}/{usage.get('limit', '?')} requests this week. "
|
|
169
|
+
f"Resets at {usage.get('resetsAt', 'next week')}. "
|
|
170
|
+
f"Upgrade at {data.get('upgrade_url', 'https://artifex.kindly.fyi/upgrade')}",
|
|
171
|
+
status_code=402,
|
|
172
|
+
details=json.dumps(data),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if response.status_code != 200:
|
|
176
|
+
try:
|
|
177
|
+
error_data = response.json()
|
|
178
|
+
error_msg = error_data.get("message") or error_data.get("error") or "Unknown error"
|
|
179
|
+
details = error_data.get("details")
|
|
180
|
+
except Exception:
|
|
181
|
+
error_msg = response.text or "Unknown error"
|
|
182
|
+
details = None
|
|
183
|
+
|
|
184
|
+
raise LLMProxyError(
|
|
185
|
+
f"LLM request failed: {error_msg}",
|
|
186
|
+
status_code=response.status_code,
|
|
187
|
+
details=details,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Parse successful response (Anthropic format)
|
|
191
|
+
data = response.json()
|
|
192
|
+
|
|
193
|
+
# Extract content from Anthropic response format
|
|
194
|
+
content = ""
|
|
195
|
+
if "content" in data:
|
|
196
|
+
# Anthropic format: {"content": [{"type": "text", "text": "..."}]}
|
|
197
|
+
for block in data["content"]:
|
|
198
|
+
if block.get("type") == "text":
|
|
199
|
+
content += block.get("text", "")
|
|
200
|
+
elif "choices" in data:
|
|
201
|
+
# OpenAI format
|
|
202
|
+
content = data["choices"][0]["message"]["content"]
|
|
203
|
+
else:
|
|
204
|
+
content = str(data)
|
|
205
|
+
|
|
206
|
+
# Extract usage
|
|
207
|
+
usage = data.get("usage", {})
|
|
208
|
+
input_tokens = usage.get("input_tokens", 0)
|
|
209
|
+
output_tokens = usage.get("output_tokens", 0)
|
|
210
|
+
|
|
211
|
+
return LLMResponse(
|
|
212
|
+
content=content,
|
|
213
|
+
input_tokens=input_tokens,
|
|
214
|
+
output_tokens=output_tokens,
|
|
215
|
+
model=data.get("model", model),
|
|
216
|
+
stop_reason=data.get("stop_reason", "unknown"),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def is_authenticated(self) -> bool:
|
|
220
|
+
"""Check if we have a valid access token."""
|
|
221
|
+
return bool(self.access_token)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def get_proxy() -> LLMProxy:
|
|
225
|
+
"""
|
|
226
|
+
Get a configured LLM proxy instance.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
LLMProxy instance ready to use
|
|
230
|
+
|
|
231
|
+
Raises:
|
|
232
|
+
LLMProxyError: If not authenticated
|
|
233
|
+
"""
|
|
234
|
+
proxy = LLMProxy()
|
|
235
|
+
if not proxy.is_authenticated():
|
|
236
|
+
raise LLMProxyError(
|
|
237
|
+
"Not authenticated. Run 'ate login' to authenticate with FoodforThought."
|
|
238
|
+
)
|
|
239
|
+
return proxy
|