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 +27 -0
- thorbit/client.py +299 -0
- thorbit/generated_tools.py +6458 -0
- thorbit_sdk-0.2.0.dist-info/METADATA +48 -0
- thorbit_sdk-0.2.0.dist-info/RECORD +8 -0
- thorbit_sdk-0.2.0.dist-info/WHEEL +5 -0
- thorbit_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
- thorbit_sdk-0.2.0.dist-info/top_level.txt +1 -0
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
|
+
]
|