thorbit-sdk 0.2.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.
thorbit/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """Public Thorbit Python SDK surface."""
2
+
3
+ from .client import (
4
+ CallThorbitTools,
5
+ ThorbitClientOptions,
6
+ ThorbitClientProtocol,
7
+ ThorbitHttpError,
8
+ ThorbitRequestValidationError,
9
+ ThorbitResponseValidationError,
10
+ ThorbitResultModelError,
11
+ ThorbitSdkError,
12
+ ThorbitTimeoutError,
13
+ ThorbitTransportError,
14
+ )
15
+
16
+ __all__ = [
17
+ "CallThorbitTools",
18
+ "ThorbitClientOptions",
19
+ "ThorbitClientProtocol",
20
+ "ThorbitHttpError",
21
+ "ThorbitRequestValidationError",
22
+ "ThorbitResponseValidationError",
23
+ "ThorbitResultModelError",
24
+ "ThorbitSdkError",
25
+ "ThorbitTimeoutError",
26
+ "ThorbitTransportError",
27
+ ]
thorbit/client.py ADDED
@@ -0,0 +1,299 @@
1
+ """Validated synchronous transport for the unified Thorbit tool API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from types import TracebackType
7
+ from typing import Protocol, TypeVar, cast, runtime_checkable
8
+ from urllib.parse import quote
9
+
10
+ import httpx
11
+ from pydantic import (
12
+ AnyHttpUrl,
13
+ BaseModel,
14
+ ConfigDict,
15
+ Field,
16
+ SecretStr,
17
+ TypeAdapter,
18
+ ValidationError,
19
+ field_validator,
20
+ )
21
+ from pydantic.errors import PydanticUserError
22
+
23
+ TResult = TypeVar("TResult")
24
+
25
+ DEFAULT_THORBIT_BASE_URL = "https://thorbit.ai"
26
+ DEFAULT_THORBIT_TIMEOUT_SECONDS = 120.0
27
+ THORBIT_TOOL_ROUTE = "/api/v1/mcp/thorbit"
28
+
29
+
30
+ class ThorbitClientOptions(BaseModel):
31
+ """Validated connection options whose representation redacts the API key."""
32
+
33
+ model_config = ConfigDict(
34
+ extra="forbid",
35
+ frozen=True,
36
+ str_strip_whitespace=True,
37
+ validate_default=True,
38
+ )
39
+
40
+ api_key: SecretStr
41
+ base_url: AnyHttpUrl = Field(default=DEFAULT_THORBIT_BASE_URL)
42
+ timeout_seconds: float = Field(
43
+ default=DEFAULT_THORBIT_TIMEOUT_SECONDS,
44
+ ge=1,
45
+ le=300,
46
+ )
47
+
48
+ @field_validator("api_key")
49
+ @classmethod
50
+ def validate_api_key(cls, api_key: SecretStr) -> SecretStr:
51
+ if not api_key.get_secret_value().strip():
52
+ raise ValueError("api_key must not be empty")
53
+ return api_key
54
+
55
+ @field_validator("base_url")
56
+ @classmethod
57
+ def validate_base_url(cls, base_url: AnyHttpUrl) -> AnyHttpUrl:
58
+ if base_url.username is not None or base_url.password is not None:
59
+ raise ValueError("base_url must not contain credentials")
60
+ if base_url.query is not None or base_url.fragment is not None:
61
+ raise ValueError("base_url must not contain a query or fragment")
62
+ return base_url
63
+
64
+
65
+ @runtime_checkable
66
+ class ThorbitClientProtocol(Protocol):
67
+ """Structural contract implemented by the generic and generated clients."""
68
+
69
+ def call_tool(
70
+ self,
71
+ tool_name: str,
72
+ input: Mapping[str, object],
73
+ result_model: type[TResult],
74
+ ) -> TResult:
75
+ """Call one exact MCP tool and validate its response model."""
76
+ ...
77
+
78
+
79
+ class ThorbitSdkError(Exception):
80
+ """Base class for stable, catchable Thorbit SDK failures."""
81
+
82
+
83
+ class ThorbitRequestValidationError(ThorbitSdkError):
84
+ """The local tool invocation could not be safely serialized."""
85
+
86
+
87
+ class ThorbitTransportError(ThorbitSdkError):
88
+ """The Thorbit HTTP exchange did not produce a response."""
89
+
90
+
91
+ class ThorbitTimeoutError(ThorbitTransportError):
92
+ """The Thorbit request exceeded the configured timeout."""
93
+
94
+ def __init__(self, timeout_seconds: float) -> None:
95
+ super().__init__(
96
+ f"Thorbit API request timed out after {timeout_seconds:g} seconds"
97
+ )
98
+ self.timeout_seconds = timeout_seconds
99
+
100
+
101
+ class ThorbitHttpError(ThorbitSdkError):
102
+ """A non-success HTTP response with canonical Thorbit error metadata."""
103
+
104
+ def __init__(
105
+ self,
106
+ status_code: int,
107
+ message: str,
108
+ *,
109
+ code: str | None = None,
110
+ request_id: str | None = None,
111
+ retryable: bool | None = None,
112
+ ) -> None:
113
+ super().__init__(message)
114
+ self.status_code = status_code
115
+ self.code = code
116
+ self.request_id = request_id
117
+ self.retryable = retryable
118
+
119
+
120
+ class ThorbitResponseValidationError(ThorbitSdkError):
121
+ """A success response did not match the caller-supplied Pydantic model."""
122
+
123
+ def __init__(
124
+ self,
125
+ message: str,
126
+ *,
127
+ status_code: int,
128
+ issues: tuple[dict[str, object], ...] = (),
129
+ ) -> None:
130
+ super().__init__(message)
131
+ self.status_code = status_code
132
+ self.issues = issues
133
+
134
+
135
+ class ThorbitResultModelError(ThorbitSdkError):
136
+ """The supplied result model cannot be used by Pydantic v2."""
137
+
138
+
139
+ def _optional_string(value: object) -> str | None:
140
+ return value if isinstance(value, str) and value else None
141
+
142
+
143
+ def _http_error(response: httpx.Response, payload: object) -> ThorbitHttpError:
144
+ code: str | None = None
145
+ message: str | None = None
146
+ request_id: str | None = None
147
+ retryable: bool | None = None
148
+
149
+ if isinstance(payload, Mapping):
150
+ request_id = _optional_string(payload.get("requestId"))
151
+ error = payload.get("error")
152
+ if isinstance(error, Mapping):
153
+ code = _optional_string(error.get("code"))
154
+ message = _optional_string(error.get("message"))
155
+ raw_retryable = error.get("retryable")
156
+ retryable = raw_retryable if isinstance(raw_retryable, bool) else None
157
+ else:
158
+ message = _optional_string(payload.get("message"))
159
+ code = _optional_string(error)
160
+
161
+ return ThorbitHttpError(
162
+ response.status_code,
163
+ message or f"Thorbit API request failed with HTTP {response.status_code}",
164
+ code=code,
165
+ request_id=request_id,
166
+ retryable=retryable,
167
+ )
168
+
169
+
170
+ def _validation_issues(error: ValidationError) -> tuple[dict[str, object], ...]:
171
+ return tuple(
172
+ {
173
+ "type": issue["type"],
174
+ "location": tuple(issue["loc"]),
175
+ "message": issue["msg"],
176
+ }
177
+ for issue in error.errors(include_url=False, include_input=False)
178
+ )
179
+
180
+
181
+ class CallThorbitTools(ThorbitClientProtocol):
182
+ """Call Thorbit MCP tools through the unified authenticated REST route."""
183
+
184
+ def __init__(
185
+ self,
186
+ options: ThorbitClientOptions | Mapping[str, object],
187
+ *,
188
+ http_client: httpx.Client | None = None,
189
+ ) -> None:
190
+ self._options = (
191
+ options
192
+ if isinstance(options, ThorbitClientOptions)
193
+ else ThorbitClientOptions.model_validate(options)
194
+ )
195
+ self._owns_http_client = http_client is None
196
+ self._http_client = http_client or httpx.Client()
197
+ self._base_url = str(self._options.base_url).rstrip("/")
198
+
199
+ @property
200
+ def options(self) -> ThorbitClientOptions:
201
+ """Return the immutable, secret-redacting connection options."""
202
+ return self._options
203
+
204
+ def call_tool(
205
+ self,
206
+ tool_name: str,
207
+ input: Mapping[str, object],
208
+ result_model: type[TResult],
209
+ ) -> TResult:
210
+ """Call one tool and return a Pydantic-validated result."""
211
+ if not isinstance(tool_name, str) or not tool_name.strip():
212
+ raise ThorbitRequestValidationError("tool_name must not be empty")
213
+ if not isinstance(input, Mapping):
214
+ raise ThorbitRequestValidationError("input must be a mapping")
215
+ if not isinstance(result_model, type):
216
+ raise ThorbitResultModelError("result_model must be a type")
217
+
218
+ encoded_tool_name = quote(tool_name, safe="")
219
+ url = f"{self._base_url}{THORBIT_TOOL_ROUTE}/{encoded_tool_name}"
220
+ headers = {
221
+ "Accept": "application/vnd.thorbit.tool-result+json",
222
+ "Content-Type": "application/json",
223
+ "x-thorbit-api-key": self._options.api_key.get_secret_value(),
224
+ }
225
+
226
+ try:
227
+ response = self._http_client.post(
228
+ url,
229
+ headers=headers,
230
+ json=dict(input),
231
+ timeout=self._options.timeout_seconds,
232
+ )
233
+ except httpx.TimeoutException as error:
234
+ raise ThorbitTimeoutError(self._options.timeout_seconds) from error
235
+ except httpx.HTTPError as error:
236
+ raise ThorbitTransportError("Thorbit API network request failed") from error
237
+ except (TypeError, ValueError, OverflowError) as error:
238
+ raise ThorbitRequestValidationError(
239
+ "Thorbit tool input could not be serialized as JSON"
240
+ ) from error
241
+
242
+ try:
243
+ payload: object = response.json()
244
+ except ValueError as error:
245
+ if not response.is_success:
246
+ raise _http_error(response, None) from error
247
+ raise ThorbitResponseValidationError(
248
+ "Thorbit API returned malformed JSON",
249
+ status_code=response.status_code,
250
+ ) from error
251
+
252
+ if not response.is_success:
253
+ raise _http_error(response, payload)
254
+
255
+ try:
256
+ adapter = TypeAdapter(result_model)
257
+ except (PydanticUserError, TypeError) as error:
258
+ raise ThorbitResultModelError(
259
+ "result_model must define a Pydantic-compatible schema"
260
+ ) from error
261
+
262
+ try:
263
+ return cast(TResult, adapter.validate_python(payload))
264
+ except ValidationError as error:
265
+ raise ThorbitResponseValidationError(
266
+ "Thorbit API response failed result-model validation",
267
+ status_code=response.status_code,
268
+ issues=_validation_issues(error),
269
+ ) from error
270
+
271
+ def close(self) -> None:
272
+ """Close the internally owned HTTP client, if any."""
273
+ if self._owns_http_client:
274
+ self._http_client.close()
275
+
276
+ def __enter__(self) -> CallThorbitTools:
277
+ return self
278
+
279
+ def __exit__(
280
+ self,
281
+ exc_type: type[BaseException] | None,
282
+ exc_value: BaseException | None,
283
+ traceback: TracebackType | None,
284
+ ) -> None:
285
+ self.close()
286
+
287
+
288
+ __all__ = [
289
+ "CallThorbitTools",
290
+ "ThorbitClientOptions",
291
+ "ThorbitClientProtocol",
292
+ "ThorbitHttpError",
293
+ "ThorbitRequestValidationError",
294
+ "ThorbitResponseValidationError",
295
+ "ThorbitResultModelError",
296
+ "ThorbitSdkError",
297
+ "ThorbitTimeoutError",
298
+ "ThorbitTransportError",
299
+ ]