toolplane-python-client 0.1.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 (81) hide show
  1. toolplane/__init__.py +106 -0
  2. toolplane/common/__init__.py +93 -0
  3. toolplane/common/base_config.py +129 -0
  4. toolplane/common/base_connection_manager.py +171 -0
  5. toolplane/common/base_session_manager.py +321 -0
  6. toolplane/common/base_tool_manager.py +347 -0
  7. toolplane/common/constants.py +47 -0
  8. toolplane/common/utils.py +310 -0
  9. toolplane/core/__init__.py +67 -0
  10. toolplane/core/config.py +107 -0
  11. toolplane/core/connection.py +285 -0
  12. toolplane/core/errors.py +298 -0
  13. toolplane/core/machine.py +480 -0
  14. toolplane/core/request.py +775 -0
  15. toolplane/core/session.py +332 -0
  16. toolplane/core/session_context.py +514 -0
  17. toolplane/core/task.py +130 -0
  18. toolplane/core/tool.py +329 -0
  19. toolplane/http_core/__init__.py +37 -0
  20. toolplane/http_core/http_config.py +97 -0
  21. toolplane/http_core/http_connection.py +409 -0
  22. toolplane/http_core/http_machine.py +298 -0
  23. toolplane/http_core/http_request.py +748 -0
  24. toolplane/http_core/http_session.py +348 -0
  25. toolplane/http_core/http_session_context.py +491 -0
  26. toolplane/http_core/http_task.py +101 -0
  27. toolplane/http_core/http_tool.py +400 -0
  28. toolplane/interfaces/__init__.py +27 -0
  29. toolplane/interfaces/client_interface.py +122 -0
  30. toolplane/interfaces/connection_interface.py +193 -0
  31. toolplane/interfaces/event_interface.py +290 -0
  32. toolplane/interfaces/request_interface.py +439 -0
  33. toolplane/interfaces/session_interface.py +288 -0
  34. toolplane/interfaces/tool_interface.py +441 -0
  35. toolplane/proto/__init__.py +0 -0
  36. toolplane/proto/service_pb2.py +315 -0
  37. toolplane/proto/service_pb2_grpc.py +2240 -0
  38. toolplane/provider_cli.py +268 -0
  39. toolplane/provider_registry.py +77 -0
  40. toolplane/provider_runtime.py +302 -0
  41. toolplane/toolkits/__init__.py +0 -0
  42. toolplane/toolkits/standalone_tools/__init__.py +0 -0
  43. toolplane/toolkits/standalone_tools/create_directory.py +94 -0
  44. toolplane/toolkits/standalone_tools/create_file.py +124 -0
  45. toolplane/toolkits/standalone_tools/file_search.py +229 -0
  46. toolplane/toolkits/standalone_tools/grep_search.py +372 -0
  47. toolplane/toolkits/standalone_tools/launcher.py +146 -0
  48. toolplane/toolkits/standalone_tools/list_dir.py +395 -0
  49. toolplane/toolkits/standalone_tools/read_file.py +346 -0
  50. toolplane/toolkits/standalone_tools/replace_string_in_file.py +407 -0
  51. toolplane/toolkits/standalone_tools/run_tests.py +66 -0
  52. toolplane/toolkits/standalone_tools/semantic_search.py +485 -0
  53. toolplane/toolkits/standalone_tools/standalone_toolkit.py +979 -0
  54. toolplane/toolkits/standalone_tools/test_failure_analysis.py +618 -0
  55. toolplane/toolkits/standalone_tools/test_standalone_toolkit.py +517 -0
  56. toolplane/toolkits/swe/__init__.py +35 -0
  57. toolplane/toolkits/swe/create_directory.py +15 -0
  58. toolplane/toolkits/swe/create_file.py +15 -0
  59. toolplane/toolkits/swe/descriptions.py +273 -0
  60. toolplane/toolkits/swe/execute_bash.py +93 -0
  61. toolplane/toolkits/swe/file_editor.py +775 -0
  62. toolplane/toolkits/swe/file_search.py +16 -0
  63. toolplane/toolkits/swe/finish.py +50 -0
  64. toolplane/toolkits/swe/grep_search.py +19 -0
  65. toolplane/toolkits/swe/list_dir.py +407 -0
  66. toolplane/toolkits/swe/read_file.py +18 -0
  67. toolplane/toolkits/swe/replace_string_in_file.py +17 -0
  68. toolplane/toolkits/swe/search.py +260 -0
  69. toolplane/toolkits/swe/semantic_search.py +20 -0
  70. toolplane/toolkits/swe/str_replace_editor.py +647 -0
  71. toolplane/toolkits/swe/submit.py +29 -0
  72. toolplane/toolkits/swe/swe_toolkit.py +1296 -0
  73. toolplane/toolplane_client.py +686 -0
  74. toolplane/toolplane_http_client.py +681 -0
  75. toolplane/utils/__init__.py +3 -0
  76. toolplane/utils/schema.py +146 -0
  77. toolplane_python_client-0.1.0.dist-info/METADATA +543 -0
  78. toolplane_python_client-0.1.0.dist-info/RECORD +81 -0
  79. toolplane_python_client-0.1.0.dist-info/WHEEL +5 -0
  80. toolplane_python_client-0.1.0.dist-info/entry_points.txt +2 -0
  81. toolplane_python_client-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,285 @@
1
+ """Connection management for Toolplane client."""
2
+
3
+ import logging
4
+ import random
5
+ import threading
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import grpc
11
+
12
+ from toolplane.proto.service_pb2_grpc import (
13
+ MachinesServiceStub,
14
+ RequestsServiceStub,
15
+ SessionsServiceStub,
16
+ TasksServiceStub,
17
+ ToolServiceStub,
18
+ )
19
+
20
+ from ..common.constants import (
21
+ GRPC_MAX_RECEIVE_MESSAGE_LENGTH,
22
+ GRPC_MAX_SEND_MESSAGE_LENGTH,
23
+ )
24
+ from .config import ClientConfig
25
+ from .errors import ConnectionError, ToolplaneConnectionError
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class ConnectionManager:
31
+ """Manages gRPC connections and stubs."""
32
+
33
+ def __init__(self, config: ClientConfig):
34
+ """Initialize connection manager with configuration."""
35
+ self.config = config
36
+ self.channel: Optional[grpc.Channel] = None
37
+ self.tool_stub: Optional[ToolServiceStub] = None
38
+ self.session_stub: Optional[SessionsServiceStub] = None
39
+ self.machine_stub: Optional[MachinesServiceStub] = None
40
+ self.requests_stub: Optional[RequestsServiceStub] = None
41
+ self.tasks_stub: Optional[TasksServiceStub] = None
42
+ self.connected = False
43
+ self._connect_lock = threading.RLock()
44
+
45
+ # Retry configuration
46
+ self.max_retries = getattr(config, "max_retries", 3)
47
+ self.retry_base_delay = getattr(config, "retry_base_delay", 1.0)
48
+ self.retry_max_delay = getattr(config, "retry_max_delay", 60.0)
49
+ self.retry_backoff_factor = getattr(config, "retry_backoff_factor", 2.0)
50
+ self.connection_state = (
51
+ "disconnected" # disconnected, connecting, connected, failed
52
+ )
53
+ self.consecutive_failures = 0
54
+
55
+ def _cleanup_failed_connection(self):
56
+ """Reset stubs and close any existing channel after a failed attempt."""
57
+ if self.channel is not None:
58
+ try:
59
+ self.channel.close()
60
+ except Exception as e:
61
+ logger.debug("Error closing channel: %s", e)
62
+ self._reset_stubs()
63
+ self.connected = False
64
+ self.connection_state = "failed"
65
+
66
+ def _calculate_delay(self, attempt: int) -> float:
67
+ """Calculate delay with exponential backoff and jitter."""
68
+ delay = min(
69
+ self.retry_base_delay * (self.retry_backoff_factor**attempt),
70
+ self.retry_max_delay,
71
+ )
72
+ # Add jitter to prevent thundering herd
73
+ jitter = random.uniform(0, 0.1 * delay)
74
+ return delay + jitter
75
+
76
+ def _should_retry(self, attempt: int, exception: Exception) -> bool:
77
+ """Determine if we should retry based on exception type and attempt count."""
78
+ if attempt >= self.max_retries:
79
+ return False
80
+
81
+ # Retry on specific gRPC status codes
82
+ if isinstance(exception, grpc.RpcError):
83
+ retryable_codes = [
84
+ grpc.StatusCode.UNAVAILABLE,
85
+ grpc.StatusCode.DEADLINE_EXCEEDED,
86
+ grpc.StatusCode.RESOURCE_EXHAUSTED,
87
+ ]
88
+ return exception.code() in retryable_codes
89
+
90
+ # Retry on network-related exceptions
91
+ if isinstance(
92
+ exception, (ToolplaneConnectionError, ConnectionError, TimeoutError)
93
+ ):
94
+ return True
95
+
96
+ return False
97
+
98
+ def _load_tls_bytes(self, path_value: Optional[str], label: str) -> Optional[bytes]:
99
+ """Load TLS material from disk when configured."""
100
+ if not path_value:
101
+ return None
102
+
103
+ try:
104
+ return Path(path_value).expanduser().read_bytes()
105
+ except OSError as exc:
106
+ raise ToolplaneConnectionError(f"Failed to read {label}: {exc}") from exc
107
+
108
+ def _channel_options(self) -> list[tuple[str, object]]:
109
+ """Build gRPC channel options: the message-size ladder plus TLS
110
+ overrides.
111
+
112
+ The size options lift the client above the gRPC 4 MiB receive
113
+ default, which rejects a full 8 MiB replay-window read at the
114
+ client before it reaches application code.
115
+ """
116
+ options: list[tuple[str, object]] = [
117
+ (
118
+ "grpc.max_receive_message_length",
119
+ GRPC_MAX_RECEIVE_MESSAGE_LENGTH,
120
+ ),
121
+ ("grpc.max_send_message_length", GRPC_MAX_SEND_MESSAGE_LENGTH),
122
+ ]
123
+ server_name = getattr(self.config, "tls_server_name", None)
124
+ if server_name:
125
+ options.extend(
126
+ [
127
+ ("grpc.ssl_target_name_override", server_name),
128
+ ("grpc.default_authority", server_name),
129
+ ]
130
+ )
131
+ return options
132
+
133
+ def _create_channel(self, target: str) -> grpc.Channel:
134
+ """Create a secure or insecure gRPC channel from client configuration."""
135
+ use_tls = bool(
136
+ getattr(self.config, "use_tls", False)
137
+ or getattr(self.config, "tls_ca_cert_path", None)
138
+ or getattr(self.config, "tls_server_name", None)
139
+ or getattr(self.config, "tls_cert_path", None)
140
+ or getattr(self.config, "tls_key_path", None)
141
+ )
142
+ options = self._channel_options()
143
+ if not use_tls:
144
+ return grpc.insecure_channel(target, options=options)
145
+
146
+ certificate_chain = self._load_tls_bytes(
147
+ getattr(self.config, "tls_cert_path", None),
148
+ "TLS client certificate",
149
+ )
150
+ private_key = self._load_tls_bytes(
151
+ getattr(self.config, "tls_key_path", None),
152
+ "TLS client key",
153
+ )
154
+ if bool(certificate_chain) != bool(private_key):
155
+ raise ToolplaneConnectionError(
156
+ "TLS client authentication requires both certificate and key files"
157
+ )
158
+
159
+ root_certificates = self._load_tls_bytes(
160
+ getattr(self.config, "tls_ca_cert_path", None),
161
+ "TLS CA certificate",
162
+ )
163
+ credentials = grpc.ssl_channel_credentials(
164
+ root_certificates=root_certificates,
165
+ private_key=private_key,
166
+ certificate_chain=certificate_chain,
167
+ )
168
+ return grpc.secure_channel(target, credentials, options=options)
169
+
170
+ def connect(self) -> bool:
171
+ """Establish connection to gRPC server with retry logic."""
172
+ with self._connect_lock:
173
+ if self.connected and self.channel is not None:
174
+ return True
175
+
176
+ last_exception = None
177
+ ready_timeout = max(1, min(self.config.request_timeout, 10))
178
+
179
+ for attempt in range(self.max_retries + 1):
180
+ try:
181
+ if attempt > 0:
182
+ delay = self._calculate_delay(attempt - 1)
183
+ time.sleep(delay)
184
+ logger.debug(
185
+ "Retry attempt %d/%d after %.2fs delay",
186
+ attempt,
187
+ self.max_retries,
188
+ delay,
189
+ )
190
+
191
+ self.connection_state = "connecting"
192
+ target = self.config.get_server_address()
193
+ self.channel = self._create_channel(target)
194
+
195
+ # Wait until the channel reports ready or timeout occurs
196
+ grpc.channel_ready_future(self.channel).result(
197
+ timeout=ready_timeout
198
+ )
199
+
200
+ self._initialize_stubs()
201
+ self.connected = True
202
+ self.connection_state = "connected"
203
+ self.consecutive_failures = 0
204
+ logger.info("Successfully connected to %s", target)
205
+ return True
206
+
207
+ except Exception as e:
208
+ last_exception = e
209
+ self.consecutive_failures += 1
210
+ logger.warning("Connection attempt %d failed: %s", attempt + 1, e)
211
+ self._cleanup_failed_connection()
212
+
213
+ if attempt < self.max_retries and self._should_retry(attempt, e):
214
+ continue
215
+ break
216
+
217
+ raise ToolplaneConnectionError(
218
+ f"Failed to connect after {self.max_retries + 1} attempts. Last error: {last_exception}"
219
+ )
220
+
221
+ def disconnect(self):
222
+ """Close connection to gRPC server."""
223
+ with self._connect_lock:
224
+ if self.channel is not None:
225
+ try:
226
+ self.channel.close()
227
+ except Exception as e:
228
+ logger.debug("Error closing channel: %s", e)
229
+ self._reset_stubs()
230
+ self.connected = False
231
+ self.connection_state = "disconnected"
232
+
233
+ def _initialize_stubs(self):
234
+ """Initialize all gRPC service stubs."""
235
+ if self.channel is None:
236
+ raise ToolplaneConnectionError("Channel is not initialized")
237
+
238
+ self.tool_stub = ToolServiceStub(self.channel)
239
+ self.session_stub = SessionsServiceStub(self.channel)
240
+ self.machine_stub = MachinesServiceStub(self.channel)
241
+ self.requests_stub = RequestsServiceStub(self.channel)
242
+ self.tasks_stub = TasksServiceStub(self.channel)
243
+
244
+ def _reset_stubs(self):
245
+ """Reset all service stubs."""
246
+ self.tool_stub = None
247
+ self.session_stub = None
248
+ self.machine_stub = None
249
+ self.requests_stub = None
250
+ self.tasks_stub = None
251
+ self.channel = None
252
+
253
+ def ensure_connected(self):
254
+ """Ensure connection is established."""
255
+ if not self.connected or self.channel is None:
256
+ if not self.connect():
257
+ raise ToolplaneConnectionError("Failed to establish connection")
258
+
259
+ def get_metadata(self):
260
+ """Get metadata for gRPC calls."""
261
+ metadata = self.config.get_metadata()
262
+ # Per-machine credential for provide-scoped RPCs (set by the machine
263
+ # registration that minted it). Sent on every call; the server only
264
+ # consults it on provider operations.
265
+ if getattr(self, "machine_token", ""):
266
+ metadata = list(metadata) + [
267
+ ("x-toolplane-machine-token", self.machine_token)
268
+ ]
269
+ return metadata
270
+
271
+ def set_machine_token(self, token: str):
272
+ """Store the per-machine credential minted at registration."""
273
+ self.machine_token = token
274
+
275
+ def mark_unhealthy(self):
276
+ """Mark the current channel as unhealthy so the next call reconnects."""
277
+ with self._connect_lock:
278
+ if self.channel is not None:
279
+ try:
280
+ self.channel.close()
281
+ except Exception as e:
282
+ logger.debug("Error closing unhealthy channel: %s", e)
283
+ self._reset_stubs()
284
+ self.connected = False
285
+ self.connection_state = "disconnected"
@@ -0,0 +1,298 @@
1
+ """Error handling for Toolplane client."""
2
+
3
+ import json
4
+ from typing import Any, Optional
5
+
6
+ from grpc import StatusCode
7
+
8
+
9
+ class ToolplaneError(Exception):
10
+ """Base exception for Toolplane client errors."""
11
+
12
+ pass
13
+
14
+
15
+ class ToolplaneConnectionError(ToolplaneError):
16
+ """Error related to gRPC connection.
17
+
18
+ Named to avoid shadowing the builtin ConnectionError: modules that
19
+ imported the old name silently lost the ability to catch (or retry on)
20
+ real socket connection errors.
21
+ """
22
+
23
+ pass
24
+
25
+
26
+ # Backwards-compatible alias for the pre-1.0 name. New code should prefer
27
+ # ToolplaneConnectionError.
28
+ ConnectionError = ToolplaneConnectionError
29
+
30
+
31
+ class ToolError(ToolplaneError):
32
+ """Error related to tool operations."""
33
+
34
+ pass
35
+
36
+
37
+ class SessionError(ToolplaneError):
38
+ """Error related to session operations."""
39
+
40
+ pass
41
+
42
+
43
+ class MachineError(ToolplaneError):
44
+ """Error related to machine operations."""
45
+
46
+ pass
47
+
48
+
49
+ class RequestError(ToolplaneError):
50
+ """Error related to request operations."""
51
+
52
+ pass
53
+
54
+
55
+ class TaskError(ToolplaneError):
56
+ """Error related to task operations."""
57
+
58
+ pass
59
+
60
+
61
+ # gRPC codes where re-issuing the same call can plausibly succeed (the server
62
+ # or a proxy was momentarily unavailable, or a capacity limit will clear).
63
+ # Everything else — bad credentials, bad arguments, state conflicts,
64
+ # missing entities — is deterministic: retrying re-runs the same failure.
65
+ RETRYABLE_GRPC_CODES = frozenset(
66
+ {StatusCode.UNAVAILABLE, StatusCode.RESOURCE_EXHAUSTED}
67
+ )
68
+
69
+ # The JSON gateway translates gRPC codes to HTTP statuses and carries the
70
+ # numeric gRPC code in the error body; this table covers responses whose body
71
+ # is not that envelope (bare proxies, load balancers).
72
+ _HTTP_STATUS_FALLBACK = {
73
+ 400: StatusCode.INVALID_ARGUMENT,
74
+ 401: StatusCode.UNAUTHENTICATED,
75
+ 403: StatusCode.PERMISSION_DENIED,
76
+ 404: StatusCode.NOT_FOUND,
77
+ 409: StatusCode.ALREADY_EXISTS,
78
+ 422: StatusCode.INVALID_ARGUMENT,
79
+ 429: StatusCode.RESOURCE_EXHAUSTED,
80
+ 500: StatusCode.INTERNAL,
81
+ 502: StatusCode.UNAVAILABLE,
82
+ 503: StatusCode.UNAVAILABLE,
83
+ 504: StatusCode.DEADLINE_EXCEEDED,
84
+ }
85
+
86
+ # grpc.StatusCode members are keyed by their wire strings ("failed
87
+ # precondition"), not by the protocol's numeric codes, so the numbers from
88
+ # the gateway error envelope are mapped explicitly.
89
+ _GRPC_NUMERIC_CODE = {
90
+ 0: StatusCode.OK,
91
+ 1: StatusCode.CANCELLED,
92
+ 2: StatusCode.UNKNOWN,
93
+ 3: StatusCode.INVALID_ARGUMENT,
94
+ 4: StatusCode.DEADLINE_EXCEEDED,
95
+ 5: StatusCode.NOT_FOUND,
96
+ 6: StatusCode.ALREADY_EXISTS,
97
+ 7: StatusCode.PERMISSION_DENIED,
98
+ 8: StatusCode.RESOURCE_EXHAUSTED,
99
+ 9: StatusCode.FAILED_PRECONDITION,
100
+ 10: StatusCode.ABORTED,
101
+ 11: StatusCode.OUT_OF_RANGE,
102
+ 12: StatusCode.UNIMPLEMENTED,
103
+ 13: StatusCode.INTERNAL,
104
+ 14: StatusCode.UNAVAILABLE,
105
+ 15: StatusCode.DATA_LOSS,
106
+ 16: StatusCode.UNAUTHENTICATED,
107
+ }
108
+
109
+
110
+ class ToolplaneAPIError(ToolplaneError):
111
+ """A server-reported failure carrying a machine-readable status code.
112
+
113
+ Attributes:
114
+ code: gRPC status name, e.g. "NOT_FOUND" or "FAILED_PRECONDITION".
115
+ retryable: True when re-issuing the call can plausibly succeed
116
+ (UNAVAILABLE or RESOURCE_EXHAUSTED).
117
+ request_id: The request the failing call operated on, when known.
118
+ status: Last-known request status for poll-style calls, when known.
119
+ details: Raw transport error (gRPC trailing status or HTTP body).
120
+ """
121
+
122
+ def __init__(
123
+ self,
124
+ message: str,
125
+ *,
126
+ code: str,
127
+ retryable: bool,
128
+ request_id: Optional[str] = None,
129
+ status: Optional[str] = None,
130
+ details: Any = None,
131
+ ):
132
+ super().__init__(message)
133
+ self.code = code
134
+ self.retryable = retryable
135
+ self.request_id = request_id
136
+ self.status = status
137
+ self.details = details
138
+
139
+
140
+ class ToolplaneNotFoundError(ToolplaneAPIError):
141
+ """The targeted session, request, machine, tool, key, or task is missing."""
142
+
143
+
144
+ class ToolplaneInvalidArgumentError(ToolplaneAPIError):
145
+ """The server rejected the request payload (INVALID_ARGUMENT)."""
146
+
147
+
148
+ class ToolplaneFailedPreconditionError(ToolplaneAPIError):
149
+ """The operation conflicts with server state: a lost claim race, a
150
+ stale lease, a draining machine, a terminal request, or no provider
151
+ registered for the tool (FAILED_PRECONDITION)."""
152
+
153
+
154
+ class ToolplaneResourceExhaustedError(ToolplaneAPIError):
155
+ """A capacity limit was hit (RESOURCE_EXHAUSTED); retryable with backoff."""
156
+
157
+
158
+ class ToolplaneUnauthenticatedError(ToolplaneAPIError):
159
+ """The presented API key is missing, unknown, or revoked
160
+ (UNAUTHENTICATED). Retrying cannot succeed."""
161
+
162
+
163
+ class ToolplanePermissionDeniedError(ToolplaneAPIError):
164
+ """The caller lacks the required capability, or the per-machine
165
+ credential check failed (PERMISSION_DENIED)."""
166
+
167
+
168
+ class ToolplaneAlreadyExistsError(ToolplaneAPIError):
169
+ """The created entity already exists (ALREADY_EXISTS)."""
170
+
171
+
172
+ class ToolplaneTimeoutError(ToolplaneAPIError):
173
+ """The call exceeded its deadline (DEADLINE_EXCEEDED)."""
174
+
175
+
176
+ class ToolplaneUnavailableError(ToolplaneAPIError):
177
+ """The server or a proxy was momentarily unreachable (UNAVAILABLE);
178
+ retryable with backoff."""
179
+
180
+
181
+ class ToolplaneCancelledError(ToolplaneAPIError):
182
+ """The call was cancelled before completing (CANCELLED)."""
183
+
184
+
185
+ class ToolplaneInternalError(ToolplaneAPIError):
186
+ """An unexpected server-side failure, or an unrecognized status code."""
187
+
188
+
189
+ _GRPC_CODE_TO_ERROR = {
190
+ StatusCode.NOT_FOUND: ToolplaneNotFoundError,
191
+ StatusCode.INVALID_ARGUMENT: ToolplaneInvalidArgumentError,
192
+ StatusCode.FAILED_PRECONDITION: ToolplaneFailedPreconditionError,
193
+ StatusCode.OUT_OF_RANGE: ToolplaneInvalidArgumentError,
194
+ StatusCode.RESOURCE_EXHAUSTED: ToolplaneResourceExhaustedError,
195
+ StatusCode.UNAUTHENTICATED: ToolplaneUnauthenticatedError,
196
+ StatusCode.PERMISSION_DENIED: ToolplanePermissionDeniedError,
197
+ StatusCode.ALREADY_EXISTS: ToolplaneAlreadyExistsError,
198
+ StatusCode.DEADLINE_EXCEEDED: ToolplaneTimeoutError,
199
+ StatusCode.UNAVAILABLE: ToolplaneUnavailableError,
200
+ StatusCode.CANCELLED: ToolplaneCancelledError,
201
+ StatusCode.INTERNAL: ToolplaneInternalError,
202
+ StatusCode.UNKNOWN: ToolplaneInternalError,
203
+ StatusCode.UNIMPLEMENTED: ToolplaneInternalError,
204
+ StatusCode.ABORTED: ToolplaneFailedPreconditionError,
205
+ }
206
+
207
+
208
+ def api_error_from_rpc_error(
209
+ rpc_error: Any,
210
+ context: str = "",
211
+ request_id: Optional[str] = None,
212
+ status: Optional[str] = None,
213
+ ) -> ToolplaneAPIError:
214
+ """Translate a grpc.RpcError into a typed ToolplaneAPIError subclass."""
215
+ code = rpc_error.code()
216
+ message = rpc_error.details() or str(rpc_error)
217
+ if context:
218
+ message = f"{context}: {message}"
219
+ error_class = _GRPC_CODE_TO_ERROR.get(code, ToolplaneInternalError)
220
+ return error_class(
221
+ message,
222
+ code=code.name,
223
+ retryable=code in RETRYABLE_GRPC_CODES,
224
+ request_id=request_id,
225
+ status=status,
226
+ details=rpc_error,
227
+ )
228
+
229
+
230
+ def api_error_from_http_response(
231
+ http_status: int,
232
+ body: str,
233
+ context: str = "",
234
+ request_id: Optional[str] = None,
235
+ ) -> ToolplaneAPIError:
236
+ """Translate an HTTP error response into a typed ToolplaneAPIError.
237
+
238
+ The JSON gateway emits ``{"code": <grpc code number>, "message": ...}``
239
+ for unary endpoints and wraps the status in an ``{"error": {...}}``
240
+ frame for streaming endpoints; the numeric code is authoritative when
241
+ present in either shape. Otherwise the HTTP status is mapped through
242
+ _HTTP_STATUS_FALLBACK.
243
+ """
244
+ message = ""
245
+ code: Optional[StatusCode] = None
246
+ try:
247
+ parsed = json.loads(body) if body else None
248
+ if isinstance(parsed, dict):
249
+ envelope = parsed
250
+ nested = parsed.get("error")
251
+ if isinstance(nested, dict):
252
+ envelope = nested
253
+ message = str(envelope.get("message") or "")
254
+ raw_code = envelope.get("code")
255
+ if raw_code is None:
256
+ raw_code = parsed.get("code")
257
+ if isinstance(raw_code, int):
258
+ code = _GRPC_NUMERIC_CODE.get(raw_code)
259
+ except (ValueError, TypeError):
260
+ pass
261
+
262
+ if code is None:
263
+ code = _HTTP_STATUS_FALLBACK.get(http_status, StatusCode.UNKNOWN)
264
+ if not message:
265
+ message = f"HTTP {http_status} {body or ''}".strip()
266
+ if context:
267
+ message = f"{context}: {message}"
268
+
269
+ error_class = _GRPC_CODE_TO_ERROR.get(code, ToolplaneInternalError)
270
+ return error_class(
271
+ message,
272
+ code=code.name,
273
+ retryable=code in RETRYABLE_GRPC_CODES,
274
+ request_id=request_id,
275
+ details=body,
276
+ )
277
+
278
+
279
+ def status_for_wire(name):
280
+ """Map a friendly status ("done", "pending") onto the v1 enum name
281
+ ("REQUEST_STATUS_DONE") used on requests with status filters. An empty
282
+ value maps to 0 (UNSPECIFIED), which gRPC accepts and means "no filter".
283
+ """
284
+ if not isinstance(name, str) or not name:
285
+ return 0
286
+ return "REQUEST_STATUS_" + name.upper()
287
+
288
+
289
+ def normalize_status_name(status):
290
+ """Map a v1 enum status name (e.g. "REQUEST_STATUS_DONE") onto the
291
+ friendly lowercase form ("done") clients have always seen. Passes
292
+ through anything else unchanged."""
293
+ if not isinstance(status, str):
294
+ return status
295
+ for prefix in ("REQUEST_STATUS_", "TASK_STATUS_"):
296
+ if status.startswith(prefix):
297
+ return status[len(prefix) :].lower()
298
+ return status