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.
Files changed (84) hide show
  1. devagent_physical_engine/__init__.py +44 -0
  2. devagent_physical_engine/agent/__init__.py +40 -0
  3. devagent_physical_engine/agent/compiler.py +285 -0
  4. devagent_physical_engine/agent/contracts.py +129 -0
  5. devagent_physical_engine/agent/coordinator.py +108 -0
  6. devagent_physical_engine/agent/critic.py +72 -0
  7. devagent_physical_engine/agent/evidence.py +34 -0
  8. devagent_physical_engine/agent/interpreter.py +179 -0
  9. devagent_physical_engine/agent/planner.py +105 -0
  10. devagent_physical_engine/agent/recovery.py +54 -0
  11. devagent_physical_engine/agent/routing.py +76 -0
  12. devagent_physical_engine/agent/runtime.py +270 -0
  13. devagent_physical_engine/agent/semantic.py +304 -0
  14. devagent_physical_engine/agent/structured.py +423 -0
  15. devagent_physical_engine/ai_cli.py +226 -0
  16. devagent_physical_engine/cli.py +392 -0
  17. devagent_physical_engine/doctor.py +20 -0
  18. devagent_physical_engine/engineering_agent.py +243 -0
  19. devagent_physical_engine/engineering_request.py +630 -0
  20. devagent_physical_engine/execution.py +90 -0
  21. devagent_physical_engine/models.py +143 -0
  22. devagent_physical_engine/operating_envelope.py +120 -0
  23. devagent_physical_engine/optimization/__init__.py +50 -0
  24. devagent_physical_engine/optimization/benchmark.py +122 -0
  25. devagent_physical_engine/optimization/candidates.py +198 -0
  26. devagent_physical_engine/optimization/contracts.py +235 -0
  27. devagent_physical_engine/optimization/evaluator.py +107 -0
  28. devagent_physical_engine/optimization/evidence.py +53 -0
  29. devagent_physical_engine/optimization/experience.py +105 -0
  30. devagent_physical_engine/optimization/measured.py +125 -0
  31. devagent_physical_engine/optimization/optimizer.py +215 -0
  32. devagent_physical_engine/optimization/orchestrator.py +155 -0
  33. devagent_physical_engine/physical_campaign.py +413 -0
  34. devagent_physical_engine/physical_evidence.py +214 -0
  35. devagent_physical_engine/physical_motion.py +196 -0
  36. devagent_physical_engine/planning.py +80 -0
  37. devagent_physical_engine/preexecution_contract.py +65 -0
  38. devagent_physical_engine/provider_adapters/__init__.py +22 -0
  39. devagent_physical_engine/provider_adapters/anthropic.py +112 -0
  40. devagent_physical_engine/provider_adapters/common.py +187 -0
  41. devagent_physical_engine/provider_adapters/factory.py +20 -0
  42. devagent_physical_engine/provider_adapters/gemini.py +126 -0
  43. devagent_physical_engine/provider_adapters/openai.py +95 -0
  44. devagent_physical_engine/provider_qualification.py +268 -0
  45. devagent_physical_engine/providers.py +94 -0
  46. devagent_physical_engine/qualification.py +44 -0
  47. devagent_physical_engine/qualification_cli.py +195 -0
  48. devagent_physical_engine/qualification_harness.py +917 -0
  49. devagent_physical_engine/robot_platform.py +411 -0
  50. devagent_physical_engine/robots.py +76 -0
  51. devagent_physical_engine/ros2/__init__.py +35 -0
  52. devagent_physical_engine/ros2/acceptance.py +324 -0
  53. devagent_physical_engine/ros2/commands.py +175 -0
  54. devagent_physical_engine/ros2/doctor.py +116 -0
  55. devagent_physical_engine/ros2/fk_probe.py +83 -0
  56. devagent_physical_engine/ros2/frame_alignment.py +61 -0
  57. devagent_physical_engine/ros2/gazebo_world.py +125 -0
  58. devagent_physical_engine/ros2/joint_state_recorder.py +64 -0
  59. devagent_physical_engine/ros2/measured_motion.py +233 -0
  60. devagent_physical_engine/ros2/moveit_scene.py +121 -0
  61. devagent_physical_engine/ros2/preexecution.py +113 -0
  62. devagent_physical_engine/ros2/qualification.py +81 -0
  63. devagent_physical_engine/ros2/qualification_v10.py +252 -0
  64. devagent_physical_engine/ros2/scene_probe.py +219 -0
  65. devagent_physical_engine/ros2/state_validity_probe.py +125 -0
  66. devagent_physical_engine/ros2/tf_probe.py +51 -0
  67. devagent_physical_engine/ros2/trajectory.py +188 -0
  68. devagent_physical_engine/ros2/ur5e.py +59 -0
  69. devagent_physical_engine/ros2/ur5e_adapter.py +349 -0
  70. devagent_physical_engine/ros2/ur5e_v10_adapter.py +292 -0
  71. devagent_physical_engine/setup_profile.py +356 -0
  72. devagent_physical_engine/simulation.py +32 -0
  73. devagent_physical_engine/simulation_platform.py +269 -0
  74. devagent_physical_engine/trajectory_qualification.py +201 -0
  75. devagent_physical_engine/twin.py +939 -0
  76. devagent_physical_engine/twin_builder.py +309 -0
  77. devagent_physical_engine/twin_materialization.py +404 -0
  78. devagent_physical_engine/verification.py +46 -0
  79. devagent_physical_engine-0.10.0.dist-info/METADATA +315 -0
  80. devagent_physical_engine-0.10.0.dist-info/RECORD +84 -0
  81. devagent_physical_engine-0.10.0.dist-info/WHEEL +5 -0
  82. devagent_physical_engine-0.10.0.dist-info/entry_points.txt +3 -0
  83. devagent_physical_engine-0.10.0.dist-info/licenses/NOTICE +2 -0
  84. 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}")