devagent-physical-engine 0.10.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.
- devagent_physical_engine/__init__.py +44 -0
- devagent_physical_engine/agent/__init__.py +40 -0
- devagent_physical_engine/agent/compiler.py +285 -0
- devagent_physical_engine/agent/contracts.py +129 -0
- devagent_physical_engine/agent/coordinator.py +108 -0
- devagent_physical_engine/agent/critic.py +72 -0
- devagent_physical_engine/agent/evidence.py +34 -0
- devagent_physical_engine/agent/interpreter.py +179 -0
- devagent_physical_engine/agent/planner.py +105 -0
- devagent_physical_engine/agent/recovery.py +54 -0
- devagent_physical_engine/agent/routing.py +76 -0
- devagent_physical_engine/agent/runtime.py +270 -0
- devagent_physical_engine/agent/semantic.py +304 -0
- devagent_physical_engine/agent/structured.py +423 -0
- devagent_physical_engine/ai_cli.py +226 -0
- devagent_physical_engine/cli.py +392 -0
- devagent_physical_engine/doctor.py +20 -0
- devagent_physical_engine/engineering_agent.py +243 -0
- devagent_physical_engine/engineering_request.py +630 -0
- devagent_physical_engine/execution.py +90 -0
- devagent_physical_engine/models.py +143 -0
- devagent_physical_engine/operating_envelope.py +120 -0
- devagent_physical_engine/optimization/__init__.py +50 -0
- devagent_physical_engine/optimization/benchmark.py +122 -0
- devagent_physical_engine/optimization/candidates.py +198 -0
- devagent_physical_engine/optimization/contracts.py +235 -0
- devagent_physical_engine/optimization/evaluator.py +107 -0
- devagent_physical_engine/optimization/evidence.py +53 -0
- devagent_physical_engine/optimization/experience.py +105 -0
- devagent_physical_engine/optimization/measured.py +125 -0
- devagent_physical_engine/optimization/optimizer.py +215 -0
- devagent_physical_engine/optimization/orchestrator.py +155 -0
- devagent_physical_engine/physical_campaign.py +413 -0
- devagent_physical_engine/physical_evidence.py +214 -0
- devagent_physical_engine/physical_motion.py +196 -0
- devagent_physical_engine/planning.py +80 -0
- devagent_physical_engine/preexecution_contract.py +65 -0
- devagent_physical_engine/provider_adapters/__init__.py +22 -0
- devagent_physical_engine/provider_adapters/anthropic.py +112 -0
- devagent_physical_engine/provider_adapters/common.py +187 -0
- devagent_physical_engine/provider_adapters/factory.py +20 -0
- devagent_physical_engine/provider_adapters/gemini.py +126 -0
- devagent_physical_engine/provider_adapters/openai.py +95 -0
- devagent_physical_engine/provider_qualification.py +268 -0
- devagent_physical_engine/providers.py +94 -0
- devagent_physical_engine/qualification.py +44 -0
- devagent_physical_engine/qualification_cli.py +195 -0
- devagent_physical_engine/qualification_harness.py +917 -0
- devagent_physical_engine/robot_platform.py +411 -0
- devagent_physical_engine/robots.py +76 -0
- devagent_physical_engine/ros2/__init__.py +35 -0
- devagent_physical_engine/ros2/acceptance.py +324 -0
- devagent_physical_engine/ros2/commands.py +175 -0
- devagent_physical_engine/ros2/doctor.py +116 -0
- devagent_physical_engine/ros2/fk_probe.py +83 -0
- devagent_physical_engine/ros2/frame_alignment.py +61 -0
- devagent_physical_engine/ros2/gazebo_world.py +125 -0
- devagent_physical_engine/ros2/joint_state_recorder.py +64 -0
- devagent_physical_engine/ros2/measured_motion.py +233 -0
- devagent_physical_engine/ros2/moveit_scene.py +121 -0
- devagent_physical_engine/ros2/preexecution.py +113 -0
- devagent_physical_engine/ros2/qualification.py +81 -0
- devagent_physical_engine/ros2/qualification_v10.py +252 -0
- devagent_physical_engine/ros2/scene_probe.py +219 -0
- devagent_physical_engine/ros2/state_validity_probe.py +125 -0
- devagent_physical_engine/ros2/tf_probe.py +51 -0
- devagent_physical_engine/ros2/trajectory.py +188 -0
- devagent_physical_engine/ros2/ur5e.py +59 -0
- devagent_physical_engine/ros2/ur5e_adapter.py +349 -0
- devagent_physical_engine/ros2/ur5e_v10_adapter.py +292 -0
- devagent_physical_engine/setup_profile.py +356 -0
- devagent_physical_engine/simulation.py +32 -0
- devagent_physical_engine/simulation_platform.py +269 -0
- devagent_physical_engine/trajectory_qualification.py +201 -0
- devagent_physical_engine/twin.py +939 -0
- devagent_physical_engine/twin_builder.py +309 -0
- devagent_physical_engine/twin_materialization.py +404 -0
- devagent_physical_engine/verification.py +46 -0
- devagent_physical_engine-0.10.0.dist-info/METADATA +315 -0
- devagent_physical_engine-0.10.0.dist-info/RECORD +84 -0
- devagent_physical_engine-0.10.0.dist-info/WHEEL +5 -0
- devagent_physical_engine-0.10.0.dist-info/entry_points.txt +3 -0
- devagent_physical_engine-0.10.0.dist-info/licenses/NOTICE +2 -0
- devagent_physical_engine-0.10.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
from hashlib import sha256
|
|
5
|
+
import json
|
|
6
|
+
from math import isfinite
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PhysicalMotionError(ValueError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _finite_tuple(values: tuple[float, ...], *, field_name: str) -> tuple[float, ...]:
|
|
19
|
+
converted = tuple(float(value) for value in values)
|
|
20
|
+
if any(not isfinite(value) for value in converted):
|
|
21
|
+
raise PhysicalMotionError(f"{field_name}_non_finite")
|
|
22
|
+
return converted
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class JointTrajectoryPoint:
|
|
27
|
+
positions_rad: tuple[float, ...]
|
|
28
|
+
time_from_start_s: float
|
|
29
|
+
velocities_rad_s: tuple[float, ...] = ()
|
|
30
|
+
accelerations_rad_s2: tuple[float, ...] = ()
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
positions = _finite_tuple(self.positions_rad, field_name="positions")
|
|
34
|
+
if not positions:
|
|
35
|
+
raise PhysicalMotionError("trajectory_point_positions_required")
|
|
36
|
+
time_s = float(self.time_from_start_s)
|
|
37
|
+
if not isfinite(time_s) or time_s < 0:
|
|
38
|
+
raise PhysicalMotionError("trajectory_point_time_invalid")
|
|
39
|
+
velocities = _finite_tuple(self.velocities_rad_s, field_name="velocities")
|
|
40
|
+
accelerations = _finite_tuple(
|
|
41
|
+
self.accelerations_rad_s2, field_name="accelerations"
|
|
42
|
+
)
|
|
43
|
+
if velocities and len(velocities) != len(positions):
|
|
44
|
+
raise PhysicalMotionError("trajectory_velocity_size_mismatch")
|
|
45
|
+
if accelerations and len(accelerations) != len(positions):
|
|
46
|
+
raise PhysicalMotionError("trajectory_acceleration_size_mismatch")
|
|
47
|
+
object.__setattr__(self, "positions_rad", positions)
|
|
48
|
+
object.__setattr__(self, "time_from_start_s", time_s)
|
|
49
|
+
object.__setattr__(self, "velocities_rad_s", velocities)
|
|
50
|
+
object.__setattr__(self, "accelerations_rad_s2", accelerations)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class PhysicalMotionPlan:
|
|
55
|
+
"""Executable motion contract produced only after high-level verification.
|
|
56
|
+
|
|
57
|
+
The contract intentionally contains no vendor motion language. Adapters
|
|
58
|
+
consume canonical joint trajectories in radians/seconds. A high-level
|
|
59
|
+
TaskGraph is not executable by a physical simulator until a motion planner
|
|
60
|
+
has produced this object.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
motion_id: str
|
|
64
|
+
robot_profile_key: str
|
|
65
|
+
joint_names: tuple[str, ...]
|
|
66
|
+
points: tuple[JointTrajectoryPoint, ...]
|
|
67
|
+
source_graph_hash: str
|
|
68
|
+
planner_id: str
|
|
69
|
+
twin_hash: str
|
|
70
|
+
constraints: tuple[str, ...] = ()
|
|
71
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
72
|
+
|
|
73
|
+
def __post_init__(self) -> None:
|
|
74
|
+
for name, value in (
|
|
75
|
+
("motion_id", self.motion_id),
|
|
76
|
+
("robot_profile_key", self.robot_profile_key),
|
|
77
|
+
("source_graph_hash", self.source_graph_hash),
|
|
78
|
+
("planner_id", self.planner_id),
|
|
79
|
+
("twin_hash", self.twin_hash),
|
|
80
|
+
):
|
|
81
|
+
if not value.strip():
|
|
82
|
+
raise PhysicalMotionError(f"{name}_required")
|
|
83
|
+
if not _SHA256_RE.fullmatch(self.source_graph_hash):
|
|
84
|
+
raise PhysicalMotionError("source_graph_hash_invalid")
|
|
85
|
+
if not _SHA256_RE.fullmatch(self.twin_hash):
|
|
86
|
+
raise PhysicalMotionError("twin_hash_invalid")
|
|
87
|
+
if not self.joint_names or any(not name.strip() for name in self.joint_names):
|
|
88
|
+
raise PhysicalMotionError("joint_names_required")
|
|
89
|
+
if len(set(self.joint_names)) != len(self.joint_names):
|
|
90
|
+
raise PhysicalMotionError("duplicate_joint_name")
|
|
91
|
+
if len(self.points) < 2:
|
|
92
|
+
raise PhysicalMotionError("trajectory_requires_two_points")
|
|
93
|
+
if not isinstance(self.metadata, Mapping):
|
|
94
|
+
raise PhysicalMotionError("motion_metadata_must_be_mapping")
|
|
95
|
+
if any(not constraint.strip() for constraint in self.constraints):
|
|
96
|
+
raise PhysicalMotionError("empty_motion_constraint")
|
|
97
|
+
|
|
98
|
+
previous = -1.0
|
|
99
|
+
for point in self.points:
|
|
100
|
+
if len(point.positions_rad) != len(self.joint_names):
|
|
101
|
+
raise PhysicalMotionError("trajectory_joint_count_mismatch")
|
|
102
|
+
if point.time_from_start_s <= previous:
|
|
103
|
+
raise PhysicalMotionError("trajectory_time_not_strictly_increasing")
|
|
104
|
+
previous = point.time_from_start_s
|
|
105
|
+
if self.points[0].time_from_start_s != 0.0:
|
|
106
|
+
raise PhysicalMotionError("trajectory_first_point_must_start_at_zero")
|
|
107
|
+
if self.points[-1].time_from_start_s <= 0.0:
|
|
108
|
+
raise PhysicalMotionError("trajectory_duration_invalid")
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
json.dumps(self.metadata, sort_keys=True, allow_nan=False)
|
|
112
|
+
except (TypeError, ValueError) as exc:
|
|
113
|
+
raise PhysicalMotionError("motion_metadata_not_json_safe") from exc
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def planned_duration_s(self) -> float:
|
|
117
|
+
return self.points[-1].time_from_start_s
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def joint_travel_rad(self) -> float:
|
|
121
|
+
total = 0.0
|
|
122
|
+
previous = self.points[0].positions_rad
|
|
123
|
+
for point in self.points[1:]:
|
|
124
|
+
total += sum(abs(a - b) for a, b in zip(previous, point.positions_rad))
|
|
125
|
+
previous = point.positions_rad
|
|
126
|
+
return total
|
|
127
|
+
|
|
128
|
+
def to_dict(self) -> dict[str, Any]:
|
|
129
|
+
return {
|
|
130
|
+
"motion_id": self.motion_id,
|
|
131
|
+
"robot_profile_key": self.robot_profile_key,
|
|
132
|
+
"joint_names": list(self.joint_names),
|
|
133
|
+
"points": [asdict(point) for point in self.points],
|
|
134
|
+
"source_graph_hash": self.source_graph_hash,
|
|
135
|
+
"planner_id": self.planner_id,
|
|
136
|
+
"twin_hash": self.twin_hash,
|
|
137
|
+
"constraints": list(self.constraints),
|
|
138
|
+
"metadata": dict(self.metadata),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def fingerprint(self) -> str:
|
|
143
|
+
payload = json.dumps(
|
|
144
|
+
self.to_dict(), sort_keys=True, separators=(",", ":"), allow_nan=False
|
|
145
|
+
)
|
|
146
|
+
return sha256(payload.encode("utf-8")).hexdigest()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True, slots=True)
|
|
150
|
+
class MotionExecutionMetrics:
|
|
151
|
+
"""Metrics directly observed or explicitly unavailable after one execution."""
|
|
152
|
+
|
|
153
|
+
success: bool
|
|
154
|
+
observed_duration_s: float | None
|
|
155
|
+
planned_duration_s: float
|
|
156
|
+
planned_joint_travel_rad: float
|
|
157
|
+
measured_joint_travel_rad: float | None = None
|
|
158
|
+
max_tracking_error_rad: float | None = None
|
|
159
|
+
path_length_m: float | None = None
|
|
160
|
+
min_clearance_m: float | None = None
|
|
161
|
+
final_tcp_error_m: float | None = None
|
|
162
|
+
energy_proxy: float | None = None
|
|
163
|
+
planner_retries: int | None = None
|
|
164
|
+
trajectory_retries: int | None = None
|
|
165
|
+
failure_codes: tuple[str, ...] = ()
|
|
166
|
+
metrics_origin: str = "measured_physical_simulation"
|
|
167
|
+
|
|
168
|
+
def __post_init__(self) -> None:
|
|
169
|
+
if not self.metrics_origin.strip():
|
|
170
|
+
raise PhysicalMotionError("metrics_origin_required")
|
|
171
|
+
for name in (
|
|
172
|
+
"observed_duration_s",
|
|
173
|
+
"planned_duration_s",
|
|
174
|
+
"planned_joint_travel_rad",
|
|
175
|
+
"measured_joint_travel_rad",
|
|
176
|
+
"max_tracking_error_rad",
|
|
177
|
+
"path_length_m",
|
|
178
|
+
"min_clearance_m",
|
|
179
|
+
"final_tcp_error_m",
|
|
180
|
+
"energy_proxy",
|
|
181
|
+
):
|
|
182
|
+
value = getattr(self, name)
|
|
183
|
+
if value is None:
|
|
184
|
+
continue
|
|
185
|
+
numeric = float(value)
|
|
186
|
+
if not isfinite(numeric) or numeric < 0:
|
|
187
|
+
raise PhysicalMotionError(f"metric_invalid:{name}")
|
|
188
|
+
for name in ("planner_retries", "trajectory_retries"):
|
|
189
|
+
value = getattr(self, name)
|
|
190
|
+
if value is not None and value < 0:
|
|
191
|
+
raise PhysicalMotionError(f"metric_invalid:{name}")
|
|
192
|
+
if any(not code.strip() for code in self.failure_codes):
|
|
193
|
+
raise PhysicalMotionError("empty_motion_failure_code")
|
|
194
|
+
|
|
195
|
+
def to_dict(self) -> dict[str, Any]:
|
|
196
|
+
return asdict(self)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from .models import ActionContract, Capability, Goal, Resource, TaskGraph, TaskNode
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PlanningError(RuntimeError):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SUPPORTED_GOAL_ACTIONS = frozenset({"load"})
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class CapabilityPlanner:
|
|
17
|
+
"""Deterministic reference planner; AI planners must emit the same verified TaskGraph schema."""
|
|
18
|
+
|
|
19
|
+
def plan(self, goal: Goal, resources: list[Resource]) -> TaskGraph:
|
|
20
|
+
action = goal.action.strip().lower()
|
|
21
|
+
if action not in SUPPORTED_GOAL_ACTIONS:
|
|
22
|
+
raise PlanningError(f"unsupported_goal_action:{action}")
|
|
23
|
+
|
|
24
|
+
arm = next(
|
|
25
|
+
(
|
|
26
|
+
resource
|
|
27
|
+
for resource in resources
|
|
28
|
+
if all(
|
|
29
|
+
resource.supports(capability)
|
|
30
|
+
for capability in (Capability.PICK, Capability.PLACE, Capability.MOVE)
|
|
31
|
+
)
|
|
32
|
+
),
|
|
33
|
+
None,
|
|
34
|
+
)
|
|
35
|
+
if arm is None:
|
|
36
|
+
raise PlanningError("no_resource_supports_pick_place")
|
|
37
|
+
|
|
38
|
+
return TaskGraph(
|
|
39
|
+
goal.goal_id,
|
|
40
|
+
(
|
|
41
|
+
TaskNode(
|
|
42
|
+
"T1",
|
|
43
|
+
ActionContract(
|
|
44
|
+
Capability.PICK,
|
|
45
|
+
arm.resource_id,
|
|
46
|
+
goal.object_id,
|
|
47
|
+
source=goal.source,
|
|
48
|
+
preconditions=("robot_ready", "object_available"),
|
|
49
|
+
expected_effects=("object_gripped",),
|
|
50
|
+
constraints=("collision_free", "joint_limits"),
|
|
51
|
+
),
|
|
52
|
+
),
|
|
53
|
+
TaskNode(
|
|
54
|
+
"T2",
|
|
55
|
+
ActionContract(
|
|
56
|
+
Capability.MOVE,
|
|
57
|
+
arm.resource_id,
|
|
58
|
+
goal.object_id,
|
|
59
|
+
destination=goal.destination,
|
|
60
|
+
preconditions=("robot_ready", "object_gripped"),
|
|
61
|
+
expected_effects=("at_destination",),
|
|
62
|
+
constraints=("collision_free", "joint_limits"),
|
|
63
|
+
),
|
|
64
|
+
depends_on=("T1",),
|
|
65
|
+
),
|
|
66
|
+
TaskNode(
|
|
67
|
+
"T3",
|
|
68
|
+
ActionContract(
|
|
69
|
+
Capability.PLACE,
|
|
70
|
+
arm.resource_id,
|
|
71
|
+
goal.object_id,
|
|
72
|
+
destination=goal.destination,
|
|
73
|
+
preconditions=("robot_ready", "object_gripped", "at_destination"),
|
|
74
|
+
expected_effects=("object_placed", "goal_complete"),
|
|
75
|
+
constraints=("collision_free", "joint_limits"),
|
|
76
|
+
),
|
|
77
|
+
depends_on=("T2",),
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Mapping
|
|
7
|
+
|
|
8
|
+
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class PreExecutionVerification:
|
|
13
|
+
"""Evidence binding a collision/path check to one exact motion and Twin.
|
|
14
|
+
|
|
15
|
+
``continuous_collision_check`` is intentionally explicit. A sampled state
|
|
16
|
+
validity sweep can be useful engineering evidence, but it cannot silently be
|
|
17
|
+
treated as continuous path verification for commissioning qualification.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
verified: bool
|
|
21
|
+
method: str
|
|
22
|
+
motion_hash: str
|
|
23
|
+
twin_hash: str
|
|
24
|
+
materialization_hash: str | None
|
|
25
|
+
continuous_collision_check: bool
|
|
26
|
+
sample_count: int = 0
|
|
27
|
+
failure_codes: tuple[str, ...] = ()
|
|
28
|
+
evidence: Mapping[str, Any] = field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
if not self.method.strip():
|
|
32
|
+
raise ValueError("preexecution_method_required")
|
|
33
|
+
for name in ("motion_hash", "twin_hash"):
|
|
34
|
+
if not _SHA256_RE.fullmatch(getattr(self, name)):
|
|
35
|
+
raise ValueError(f"preexecution_{name}_invalid")
|
|
36
|
+
if self.materialization_hash is not None and not _SHA256_RE.fullmatch(
|
|
37
|
+
self.materialization_hash
|
|
38
|
+
):
|
|
39
|
+
raise ValueError("preexecution_materialization_hash_invalid")
|
|
40
|
+
if self.sample_count < 0:
|
|
41
|
+
raise ValueError("preexecution_sample_count_invalid")
|
|
42
|
+
if any(not code.strip() for code in self.failure_codes):
|
|
43
|
+
raise ValueError("preexecution_failure_code_empty")
|
|
44
|
+
if not isinstance(self.evidence, Mapping):
|
|
45
|
+
raise ValueError("preexecution_evidence_must_be_mapping")
|
|
46
|
+
try:
|
|
47
|
+
json.dumps(self.evidence, sort_keys=True, allow_nan=False)
|
|
48
|
+
except (TypeError, ValueError) as exc:
|
|
49
|
+
raise ValueError("preexecution_evidence_not_json_safe") from exc
|
|
50
|
+
if self.verified and self.failure_codes:
|
|
51
|
+
raise ValueError("verified_preexecution_cannot_have_failure_codes")
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def commissioning_usable(self) -> bool:
|
|
55
|
+
return (
|
|
56
|
+
self.verified
|
|
57
|
+
and self.continuous_collision_check
|
|
58
|
+
and not self.failure_codes
|
|
59
|
+
and self.materialization_hash is not None
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict[str, Any]:
|
|
63
|
+
payload = asdict(self)
|
|
64
|
+
payload["commissioning_usable"] = self.commissioning_usable
|
|
65
|
+
return payload
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from .anthropic import AnthropicProvider
|
|
2
|
+
from .common import (
|
|
3
|
+
ProviderAuthenticationError,
|
|
4
|
+
ProviderCallDiagnostics,
|
|
5
|
+
ProviderRateLimited,
|
|
6
|
+
ProviderRequestError,
|
|
7
|
+
)
|
|
8
|
+
from .factory import PROVIDER_NAMES, create_provider
|
|
9
|
+
from .gemini import GeminiProvider
|
|
10
|
+
from .openai import OpenAIProvider
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AnthropicProvider",
|
|
14
|
+
"GeminiProvider",
|
|
15
|
+
"OpenAIProvider",
|
|
16
|
+
"ProviderAuthenticationError",
|
|
17
|
+
"ProviderCallDiagnostics",
|
|
18
|
+
"ProviderRateLimited",
|
|
19
|
+
"ProviderRequestError",
|
|
20
|
+
"PROVIDER_NAMES",
|
|
21
|
+
"create_provider",
|
|
22
|
+
]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
|
|
5
|
+
from ..agent.contracts import ModelRequest, ModelResponse
|
|
6
|
+
from ..agent.runtime import ProviderError, ProviderProtocolError, ProviderUnavailable
|
|
7
|
+
from .common import (
|
|
8
|
+
DiagnosticsStore,
|
|
9
|
+
ProviderCallDiagnostics,
|
|
10
|
+
classify_provider_exception,
|
|
11
|
+
model_matches,
|
|
12
|
+
parse_json_object,
|
|
13
|
+
provider_schema,
|
|
14
|
+
request_input,
|
|
15
|
+
require_api_key,
|
|
16
|
+
safe_token_count,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AnthropicProvider:
|
|
21
|
+
provider_name = "anthropic"
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
api_key: str | None = None,
|
|
26
|
+
*,
|
|
27
|
+
max_output_tokens: int = 4096,
|
|
28
|
+
client_factory: Callable[..., Any] | None = None,
|
|
29
|
+
schema_transform: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
if not 256 <= max_output_tokens <= 65536:
|
|
32
|
+
raise ValueError("max_output_tokens_out_of_range")
|
|
33
|
+
self._api_key = api_key
|
|
34
|
+
self._max_output_tokens = max_output_tokens
|
|
35
|
+
self._client_factory = client_factory
|
|
36
|
+
self._schema_transform = schema_transform
|
|
37
|
+
self._diagnostics = DiagnosticsStore()
|
|
38
|
+
|
|
39
|
+
def diagnostics(self, request_id: str) -> ProviderCallDiagnostics | None:
|
|
40
|
+
return self._diagnostics.get(request_id)
|
|
41
|
+
|
|
42
|
+
def _client_and_schema_transform(
|
|
43
|
+
self, timeout_s: float
|
|
44
|
+
) -> tuple[Any, Callable[[dict[str, Any]], dict[str, Any]] | None]:
|
|
45
|
+
key = require_api_key(self._api_key, "ANTHROPIC_API_KEY")
|
|
46
|
+
if self._client_factory is not None:
|
|
47
|
+
return (
|
|
48
|
+
self._client_factory(api_key=key, timeout=timeout_s, max_retries=0),
|
|
49
|
+
self._schema_transform,
|
|
50
|
+
)
|
|
51
|
+
try:
|
|
52
|
+
import anthropic
|
|
53
|
+
except ImportError as exc:
|
|
54
|
+
raise ProviderUnavailable("sdk_not_installed:anthropic") from exc
|
|
55
|
+
transform = self._schema_transform or getattr(anthropic, "transform_schema", None)
|
|
56
|
+
return (
|
|
57
|
+
anthropic.Anthropic(api_key=key, timeout=timeout_s, max_retries=0),
|
|
58
|
+
transform,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def generate(self, request: ModelRequest, *, model: str) -> ModelResponse:
|
|
62
|
+
try:
|
|
63
|
+
client, transform = self._client_and_schema_transform(request.timeout_s)
|
|
64
|
+
schema = provider_schema(request.output_schema)
|
|
65
|
+
if transform is not None:
|
|
66
|
+
schema = transform(schema)
|
|
67
|
+
response = client.messages.create(
|
|
68
|
+
model=model,
|
|
69
|
+
max_tokens=self._max_output_tokens,
|
|
70
|
+
system=request.system_instruction,
|
|
71
|
+
messages=[{"role": "user", "content": request_input(request)}],
|
|
72
|
+
output_config={"format": {"type": "json_schema", "schema": schema}},
|
|
73
|
+
)
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
if isinstance(exc, ProviderError):
|
|
76
|
+
raise
|
|
77
|
+
raise classify_provider_exception(exc) from exc
|
|
78
|
+
|
|
79
|
+
request_id = str(getattr(response, "id", "") or "")
|
|
80
|
+
if not request_id:
|
|
81
|
+
raise ProviderProtocolError("provider_request_id_missing")
|
|
82
|
+
resolved_model = str(getattr(response, "model", "") or "")
|
|
83
|
+
if not model_matches(model, resolved_model):
|
|
84
|
+
raise ProviderProtocolError("response_model_identity_mismatch")
|
|
85
|
+
|
|
86
|
+
text = None
|
|
87
|
+
for block in getattr(response, "content", ()) or ():
|
|
88
|
+
if getattr(block, "type", None) == "text" and getattr(block, "text", None):
|
|
89
|
+
text = block.text
|
|
90
|
+
break
|
|
91
|
+
payload = parse_json_object(text)
|
|
92
|
+
|
|
93
|
+
usage = getattr(response, "usage", None)
|
|
94
|
+
input_tokens = safe_token_count(getattr(usage, "input_tokens", None))
|
|
95
|
+
output_tokens = safe_token_count(getattr(usage, "output_tokens", None))
|
|
96
|
+
total_tokens = (
|
|
97
|
+
input_tokens + output_tokens
|
|
98
|
+
if input_tokens is not None and output_tokens is not None
|
|
99
|
+
else None
|
|
100
|
+
)
|
|
101
|
+
self._diagnostics.record(
|
|
102
|
+
ProviderCallDiagnostics(
|
|
103
|
+
request_id=request_id,
|
|
104
|
+
provider=self.provider_name,
|
|
105
|
+
requested_model=model,
|
|
106
|
+
resolved_model=resolved_model or model,
|
|
107
|
+
input_tokens=input_tokens,
|
|
108
|
+
output_tokens=output_tokens,
|
|
109
|
+
total_tokens=total_tokens,
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
return ModelResponse(self.provider_name, model, payload, request_id=request_id)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import OrderedDict
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from threading import Lock
|
|
9
|
+
from typing import Any, Mapping
|
|
10
|
+
|
|
11
|
+
from ..agent.contracts import ModelRequest
|
|
12
|
+
from ..agent.runtime import (
|
|
13
|
+
ProviderError,
|
|
14
|
+
ProviderProtocolError,
|
|
15
|
+
ProviderTimeout,
|
|
16
|
+
ProviderUnavailable,
|
|
17
|
+
)
|
|
18
|
+
from ..agent.structured import canonical_json, json_safe, redact_sensitive
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ProviderAuthenticationError(ProviderError):
|
|
22
|
+
code = "provider_authentication_error"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ProviderRateLimited(ProviderError):
|
|
26
|
+
code = "provider_rate_limited"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ProviderRequestError(ProviderError):
|
|
30
|
+
code = "provider_request_error"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class ProviderCallDiagnostics:
|
|
35
|
+
request_id: str
|
|
36
|
+
provider: str
|
|
37
|
+
requested_model: str
|
|
38
|
+
resolved_model: str
|
|
39
|
+
input_tokens: int | None = None
|
|
40
|
+
output_tokens: int | None = None
|
|
41
|
+
total_tokens: int | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DiagnosticsStore:
|
|
45
|
+
"""Bounded, thread-safe metadata store. Never stores prompts or credentials."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, *, max_entries: int = 256) -> None:
|
|
48
|
+
if not 8 <= max_entries <= 4096:
|
|
49
|
+
raise ValueError("diagnostics_max_entries_out_of_range")
|
|
50
|
+
self._max_entries = max_entries
|
|
51
|
+
self._lock = Lock()
|
|
52
|
+
self._items: OrderedDict[str, ProviderCallDiagnostics] = OrderedDict()
|
|
53
|
+
|
|
54
|
+
def record(self, diagnostics: ProviderCallDiagnostics) -> None:
|
|
55
|
+
if not diagnostics.request_id:
|
|
56
|
+
return
|
|
57
|
+
with self._lock:
|
|
58
|
+
self._items[diagnostics.request_id] = diagnostics
|
|
59
|
+
self._items.move_to_end(diagnostics.request_id)
|
|
60
|
+
while len(self._items) > self._max_entries:
|
|
61
|
+
self._items.popitem(last=False)
|
|
62
|
+
|
|
63
|
+
def get(self, request_id: str) -> ProviderCallDiagnostics | None:
|
|
64
|
+
with self._lock:
|
|
65
|
+
return self._items.get(request_id)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def require_api_key(explicit: str | None, *env_names: str) -> str:
|
|
69
|
+
if explicit and explicit.strip():
|
|
70
|
+
return explicit.strip()
|
|
71
|
+
for name in env_names:
|
|
72
|
+
value = os.getenv(name)
|
|
73
|
+
if value and value.strip():
|
|
74
|
+
return value.strip()
|
|
75
|
+
raise ProviderAuthenticationError("credential_missing")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def model_matches(requested: str, resolved: str | None) -> bool:
|
|
79
|
+
"""Accept an exact model or a provider-resolved dated version of an alias."""
|
|
80
|
+
if not resolved:
|
|
81
|
+
return True
|
|
82
|
+
requested = requested.strip()
|
|
83
|
+
resolved = resolved.strip()
|
|
84
|
+
if requested == resolved:
|
|
85
|
+
return True
|
|
86
|
+
return resolved.startswith(requested + "-")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_json_object(text: Any) -> Mapping[str, Any]:
|
|
90
|
+
if not isinstance(text, str) or not text.strip():
|
|
91
|
+
raise ProviderProtocolError("provider_output_text_missing")
|
|
92
|
+
try:
|
|
93
|
+
value = json.loads(text)
|
|
94
|
+
except (TypeError, json.JSONDecodeError) as exc:
|
|
95
|
+
raise ProviderProtocolError("provider_output_invalid_json") from exc
|
|
96
|
+
if not isinstance(value, Mapping):
|
|
97
|
+
raise ProviderProtocolError("provider_output_must_be_object")
|
|
98
|
+
return value
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def request_input(request: ModelRequest) -> str:
|
|
102
|
+
return canonical_json(
|
|
103
|
+
{
|
|
104
|
+
"agent_role": request.role.value,
|
|
105
|
+
"input": json_safe(redact_sensitive(request.input_payload)),
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
_UNSUPPORTED_SCHEMA_KEYS = {
|
|
111
|
+
"minLength",
|
|
112
|
+
"maxLength",
|
|
113
|
+
"minimum",
|
|
114
|
+
"maximum",
|
|
115
|
+
"exclusiveMinimum",
|
|
116
|
+
"exclusiveMaximum",
|
|
117
|
+
"multipleOf",
|
|
118
|
+
"minItems",
|
|
119
|
+
"maxItems",
|
|
120
|
+
"uniqueItems",
|
|
121
|
+
"pattern",
|
|
122
|
+
"format",
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def provider_schema(schema: Mapping[str, Any]) -> dict[str, Any]:
|
|
127
|
+
"""Return a constrained-decoding schema; core validation remains authoritative.
|
|
128
|
+
|
|
129
|
+
Provider structured-output implementations support slightly different JSON
|
|
130
|
+
Schema subsets. Numeric/string/array constraints are intentionally stripped
|
|
131
|
+
here and enforced again by DevAgent's deterministic parser/compiler.
|
|
132
|
+
"""
|
|
133
|
+
source = deepcopy(dict(schema))
|
|
134
|
+
|
|
135
|
+
def clean(value: Any) -> Any:
|
|
136
|
+
if isinstance(value, list):
|
|
137
|
+
return [clean(item) for item in value]
|
|
138
|
+
if not isinstance(value, dict):
|
|
139
|
+
return value
|
|
140
|
+
result: dict[str, Any] = {}
|
|
141
|
+
for key, item in value.items():
|
|
142
|
+
if key in _UNSUPPORTED_SCHEMA_KEYS:
|
|
143
|
+
continue
|
|
144
|
+
result[key] = clean(item)
|
|
145
|
+
if result.get("type") == "object" or "properties" in result:
|
|
146
|
+
result["additionalProperties"] = False
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
return clean(source)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def classify_provider_exception(exc: BaseException) -> ProviderError:
|
|
153
|
+
"""Map SDK exceptions to stable cross-provider error codes without leaking text."""
|
|
154
|
+
status = getattr(exc, "status_code", None)
|
|
155
|
+
if status is None:
|
|
156
|
+
status = getattr(exc, "code", None)
|
|
157
|
+
try:
|
|
158
|
+
status_int = int(status) if status is not None else None
|
|
159
|
+
except (TypeError, ValueError):
|
|
160
|
+
status_int = None
|
|
161
|
+
|
|
162
|
+
name = type(exc).__name__.lower()
|
|
163
|
+
if status_int in {401, 403} or "auth" in name or "permission" in name:
|
|
164
|
+
return ProviderAuthenticationError("provider_authentication_failed")
|
|
165
|
+
if status_int == 429 or "ratelimit" in name or "rate_limit" in name:
|
|
166
|
+
return ProviderRateLimited("provider_rate_limited")
|
|
167
|
+
if status_int in {408, 504} or "timeout" in name:
|
|
168
|
+
return ProviderTimeout("provider_timeout")
|
|
169
|
+
if status_int is not None and 400 <= status_int < 500:
|
|
170
|
+
return ProviderRequestError("provider_request_rejected")
|
|
171
|
+
if (
|
|
172
|
+
(status_int is not None and status_int >= 500)
|
|
173
|
+
or "connection" in name
|
|
174
|
+
or "unavailable" in name
|
|
175
|
+
):
|
|
176
|
+
return ProviderUnavailable("provider_unavailable")
|
|
177
|
+
return ProviderUnavailable("provider_unavailable")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def safe_token_count(value: Any) -> int | None:
|
|
181
|
+
if value is None or isinstance(value, bool):
|
|
182
|
+
return None
|
|
183
|
+
try:
|
|
184
|
+
parsed = int(value)
|
|
185
|
+
except (TypeError, ValueError):
|
|
186
|
+
return None
|
|
187
|
+
return parsed if parsed >= 0 else None
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .anthropic import AnthropicProvider
|
|
6
|
+
from .gemini import GeminiProvider
|
|
7
|
+
from .openai import OpenAIProvider
|
|
8
|
+
|
|
9
|
+
PROVIDER_NAMES = ("openai", "anthropic", "gemini")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_provider(name: str, *, api_key: str | None = None, **kwargs: Any):
|
|
13
|
+
normalized = name.strip().lower()
|
|
14
|
+
if normalized == "openai":
|
|
15
|
+
return OpenAIProvider(api_key, **kwargs)
|
|
16
|
+
if normalized in {"anthropic", "claude"}:
|
|
17
|
+
return AnthropicProvider(api_key, **kwargs)
|
|
18
|
+
if normalized in {"gemini", "google"}:
|
|
19
|
+
return GeminiProvider(api_key, **kwargs)
|
|
20
|
+
raise ValueError(f"unsupported_provider:{normalized}")
|