alion 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.
- alion/__init__.py +29 -0
- alion/_client.py +315 -0
- alion/_errors.py +84 -0
- alion/_tools.py +294 -0
- alion/_types.py +3096 -0
- alion/_version.py +1 -0
- alion/py.typed +0 -0
- alion-0.1.0.dist-info/METADATA +163 -0
- alion-0.1.0.dist-info/RECORD +11 -0
- alion-0.1.0.dist-info/WHEEL +4 -0
- alion-0.1.0.dist-info/licenses/LICENSE +21 -0
alion/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Official Python client for Alion: companies, startups, investors, job postings, hiring and pay
|
|
2
|
+
statistics, and people open to offers, for code and AI agents. https://alion.io/developers
|
|
3
|
+
|
|
4
|
+
>>> from alion import Alion
|
|
5
|
+
>>> alion = Alion() # free without a key
|
|
6
|
+
>>> alion.search_companies(industry="Fintech", country="DE", startup=True, limit=10)["items"]
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from . import _types as types
|
|
10
|
+
from ._client import DEFAULT_BASE_URL, PROTOCOL_VERSION, Alion, AsyncAlion, Response, Transport, urllib_transport
|
|
11
|
+
from ._errors import AlionError
|
|
12
|
+
from ._types import PAGED_TOOLS, READ_ONLY_TOOLS, TOOL_NAMES
|
|
13
|
+
from ._version import __version__
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Alion",
|
|
17
|
+
"AsyncAlion",
|
|
18
|
+
"AlionError",
|
|
19
|
+
"types",
|
|
20
|
+
"PAGED_TOOLS",
|
|
21
|
+
"READ_ONLY_TOOLS",
|
|
22
|
+
"TOOL_NAMES",
|
|
23
|
+
"PROTOCOL_VERSION",
|
|
24
|
+
"DEFAULT_BASE_URL",
|
|
25
|
+
"Response",
|
|
26
|
+
"Transport",
|
|
27
|
+
"urllib_transport",
|
|
28
|
+
"__version__",
|
|
29
|
+
]
|
alion/_client.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import functools
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import socket
|
|
9
|
+
import time
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
from typing import Any, AsyncIterator, Callable, Dict, Iterator, List, Mapping, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from ._errors import AlionError
|
|
15
|
+
from ._tools import AsyncTools, SyncTools
|
|
16
|
+
from ._types import PAGED_TOOLS, READ_ONLY_TOOLS
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
|
|
19
|
+
PROTOCOL_VERSION = "2025-11-25"
|
|
20
|
+
"""The protocol revision this client speaks (the server also accepts older ones)."""
|
|
21
|
+
|
|
22
|
+
DEFAULT_BASE_URL = "https://alion.io"
|
|
23
|
+
|
|
24
|
+
Response = Tuple[int, Dict[str, str], str]
|
|
25
|
+
"""What a transport returns: HTTP status, headers with lower-case names, body text."""
|
|
26
|
+
|
|
27
|
+
Transport = Callable[[str, Dict[str, str], bytes, float], Response]
|
|
28
|
+
"""A transport: (url, headers, body, timeout seconds) -> Response. Raise on a network failure."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def urllib_transport(url: str, headers: Dict[str, str], body: bytes, timeout: float) -> Response:
|
|
32
|
+
"""The default transport: the standard library, no dependencies."""
|
|
33
|
+
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
|
34
|
+
try:
|
|
35
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
36
|
+
return r.status, {k.lower(): v for k, v in r.headers.items()}, r.read().decode("utf-8", "replace")
|
|
37
|
+
except urllib.error.HTTPError as e:
|
|
38
|
+
text = e.read().decode("utf-8", "replace") if e.fp is not None else ""
|
|
39
|
+
return e.code, {k.lower(): v for k, v in (e.headers.items() if e.headers else [])}, text
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _Base:
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
api_key: Optional[str] = None,
|
|
46
|
+
*,
|
|
47
|
+
base_url: Optional[str] = None,
|
|
48
|
+
timeout: float = 60.0,
|
|
49
|
+
max_retries: int = 2,
|
|
50
|
+
app_name: Optional[str] = None,
|
|
51
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
52
|
+
transport: Optional[Transport] = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
key = os.environ.get("ALION_API_KEY", "") if api_key is None else api_key
|
|
55
|
+
self.api_key: Optional[str] = key or None
|
|
56
|
+
self.base_url: str = (base_url or os.environ.get("ALION_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
57
|
+
self.endpoint: str = self.base_url + ("/mcp" if self.api_key else "/mcp/open")
|
|
58
|
+
self.timeout = float(timeout)
|
|
59
|
+
self.max_retries = max(0, int(max_retries))
|
|
60
|
+
self._transport: Transport = transport or urllib_transport
|
|
61
|
+
self._user_agent = f"alion-python/{__version__}" + (f" {app_name}" if app_name else "")
|
|
62
|
+
self._extra_headers = dict(headers or {})
|
|
63
|
+
self._next_id = 0
|
|
64
|
+
|
|
65
|
+
def __repr__(self) -> str:
|
|
66
|
+
return f"{type(self).__name__}(endpoint={self.endpoint!r}, key={'yes' if self.api_key else 'no'})"
|
|
67
|
+
|
|
68
|
+
def _build(self, method: str, params: Dict[str, Any]) -> Tuple[Dict[str, str], bytes]:
|
|
69
|
+
self._next_id += 1
|
|
70
|
+
body = json.dumps({"jsonrpc": "2.0", "id": self._next_id, "method": method, "params": params}).encode("utf-8")
|
|
71
|
+
headers = {
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
"Accept": "application/json, text/event-stream",
|
|
74
|
+
"MCP-Protocol-Version": PROTOCOL_VERSION,
|
|
75
|
+
"User-Agent": self._user_agent,
|
|
76
|
+
}
|
|
77
|
+
headers.update(self._extra_headers)
|
|
78
|
+
if self.api_key:
|
|
79
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
80
|
+
return headers, body
|
|
81
|
+
|
|
82
|
+
def _parse(self, status: int, headers: Mapping[str, str], text: str) -> Dict[str, Any]:
|
|
83
|
+
data = _parse_body(text, headers.get("content-type", ""))
|
|
84
|
+
if status < 200 or status >= 300:
|
|
85
|
+
raise AlionError._from_http(status, data, headers)
|
|
86
|
+
if not isinstance(data, dict):
|
|
87
|
+
raise AlionError(f"unreadable answer from {self.endpoint} (HTTP {status})", code="bad_response", status=status)
|
|
88
|
+
if isinstance(data.get("error"), dict):
|
|
89
|
+
raise AlionError._from_rpc(data["error"])
|
|
90
|
+
return data
|
|
91
|
+
|
|
92
|
+
def _network_error(self, e: BaseException) -> AlionError:
|
|
93
|
+
if isinstance(e, (socket.timeout, TimeoutError)) or "timed out" in str(e):
|
|
94
|
+
return AlionError(f"no answer from {self.endpoint} in time: {e}", code="timeout")
|
|
95
|
+
return AlionError(f"network error calling {self.endpoint}: {e}", code="network_error")
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _is_timeout(e: BaseException) -> bool:
|
|
99
|
+
return isinstance(e, (socket.timeout, TimeoutError)) or (isinstance(e, urllib.error.URLError) and isinstance(e.reason, (socket.timeout, TimeoutError)))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Alion(_Base, SyncTools):
|
|
103
|
+
"""The Alion client: every tool of the Alion MCP server as a method.
|
|
104
|
+
|
|
105
|
+
>>> from alion import Alion
|
|
106
|
+
>>> alion = Alion() # no key: the free open address
|
|
107
|
+
>>> alion.search_companies(industry="Fintech", country="DE", startup=True, limit=10)["items"]
|
|
108
|
+
|
|
109
|
+
``api_key`` defaults to the ALION_API_KEY environment variable; ``Alion(api_key="")`` forces the open address.
|
|
110
|
+
Without a key: compact company, investor and job searches, lookalikes, job postings, the people and agent tools,
|
|
111
|
+
free within a daily allowance per network. With a key (https://alion.io/developers/keys): everything.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def call(self, name: str, args: Optional[Dict[str, Any]] = None, *, timeout: Optional[float] = None) -> Any:
|
|
115
|
+
"""Calls a tool by name. The typed methods (search_companies, get_company, ...) are shortcuts for this."""
|
|
116
|
+
rpc = self._rpc("tools/call", {"name": name, "arguments": _clean(args)}, timeout, name in READ_ONLY_TOOLS)
|
|
117
|
+
return _tool_result(name, rpc)
|
|
118
|
+
|
|
119
|
+
def paginate(self, name: str, *, max_items: Optional[int] = None, timeout: Optional[float] = None, **args: Any) -> Iterator[Dict[str, Any]]:
|
|
120
|
+
"""Walks every page of a search, yielding one row at a time (searches stop at row 100 on the server).
|
|
121
|
+
|
|
122
|
+
>>> for job in alion.paginate("search_jobs", role="Data Engineer", country="PL", max_items=50): ...
|
|
123
|
+
"""
|
|
124
|
+
if name not in PAGED_TOOLS:
|
|
125
|
+
raise ValueError(f"{name} does not page")
|
|
126
|
+
cursor: Optional[str] = None
|
|
127
|
+
seen = 0
|
|
128
|
+
while True:
|
|
129
|
+
page = self.call(name, {**args, **({"cursor": cursor} if cursor else {})}, timeout=timeout)
|
|
130
|
+
items = page.get("items") or []
|
|
131
|
+
for item in items:
|
|
132
|
+
yield item
|
|
133
|
+
seen += 1
|
|
134
|
+
if max_items is not None and seen >= max_items:
|
|
135
|
+
return
|
|
136
|
+
cursor = page.get("next_cursor")
|
|
137
|
+
if not cursor or page.get("has_more") is False or not items:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
def list_tools(self, *, timeout: Optional[float] = None) -> List[Dict[str, Any]]:
|
|
141
|
+
"""The tools this address offers, with their input and output schemas."""
|
|
142
|
+
tools: List[Dict[str, Any]] = []
|
|
143
|
+
cursor: Optional[str] = None
|
|
144
|
+
while True:
|
|
145
|
+
res = self._rpc("tools/list", {"cursor": cursor} if cursor else {}, timeout, True).get("result") or {}
|
|
146
|
+
tools.extend(res.get("tools") or [])
|
|
147
|
+
cursor = res.get("nextCursor")
|
|
148
|
+
if not cursor:
|
|
149
|
+
return tools
|
|
150
|
+
|
|
151
|
+
def close(self) -> None:
|
|
152
|
+
"""Nothing to release (every call is one stateless request); here for the with-statement."""
|
|
153
|
+
|
|
154
|
+
def __enter__(self) -> "Alion":
|
|
155
|
+
return self
|
|
156
|
+
|
|
157
|
+
def __exit__(self, *exc: Any) -> None:
|
|
158
|
+
self.close()
|
|
159
|
+
|
|
160
|
+
def _rpc(self, method: str, params: Dict[str, Any], timeout: Optional[float], retry_5xx: bool) -> Dict[str, Any]:
|
|
161
|
+
headers, body = self._build(method, params)
|
|
162
|
+
attempt = 0
|
|
163
|
+
while True:
|
|
164
|
+
try:
|
|
165
|
+
status, h, text = self._transport(self.endpoint, headers, body, timeout or self.timeout)
|
|
166
|
+
except AlionError:
|
|
167
|
+
raise
|
|
168
|
+
except Exception as e: # network failure
|
|
169
|
+
if self._is_timeout(e):
|
|
170
|
+
raise self._network_error(TimeoutError(str(e))) from e
|
|
171
|
+
if attempt < self.max_retries:
|
|
172
|
+
time.sleep(_backoff(attempt))
|
|
173
|
+
attempt += 1
|
|
174
|
+
continue
|
|
175
|
+
raise self._network_error(e) from e
|
|
176
|
+
if _retryable(status, retry_5xx) and attempt < self.max_retries:
|
|
177
|
+
ra = _retry_after(h)
|
|
178
|
+
time.sleep(ra if ra is not None else _backoff(attempt))
|
|
179
|
+
attempt += 1
|
|
180
|
+
continue
|
|
181
|
+
return self._parse(status, h, text)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class AsyncAlion(_Base, AsyncTools):
|
|
185
|
+
"""The async client: the same methods as :class:`Alion`, awaited.
|
|
186
|
+
|
|
187
|
+
>>> from alion import AsyncAlion
|
|
188
|
+
>>> alion = AsyncAlion()
|
|
189
|
+
>>> result = await alion.search_jobs(query="rust", country="DE")
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
async def call(self, name: str, args: Optional[Dict[str, Any]] = None, *, timeout: Optional[float] = None) -> Any:
|
|
193
|
+
"""Calls a tool by name. The typed methods are shortcuts for this."""
|
|
194
|
+
rpc = await self._rpc("tools/call", {"name": name, "arguments": _clean(args)}, timeout, name in READ_ONLY_TOOLS)
|
|
195
|
+
return _tool_result(name, rpc)
|
|
196
|
+
|
|
197
|
+
async def paginate(self, name: str, *, max_items: Optional[int] = None, timeout: Optional[float] = None, **args: Any) -> AsyncIterator[Dict[str, Any]]:
|
|
198
|
+
"""Walks every page of a search, yielding one row at a time.
|
|
199
|
+
|
|
200
|
+
>>> async for job in alion.paginate("search_jobs", role="Data Engineer", max_items=50): ...
|
|
201
|
+
"""
|
|
202
|
+
if name not in PAGED_TOOLS:
|
|
203
|
+
raise ValueError(f"{name} does not page")
|
|
204
|
+
cursor: Optional[str] = None
|
|
205
|
+
seen = 0
|
|
206
|
+
while True:
|
|
207
|
+
page = await self.call(name, {**args, **({"cursor": cursor} if cursor else {})}, timeout=timeout)
|
|
208
|
+
items = page.get("items") or []
|
|
209
|
+
for item in items:
|
|
210
|
+
yield item
|
|
211
|
+
seen += 1
|
|
212
|
+
if max_items is not None and seen >= max_items:
|
|
213
|
+
return
|
|
214
|
+
cursor = page.get("next_cursor")
|
|
215
|
+
if not cursor or page.get("has_more") is False or not items:
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
async def list_tools(self, *, timeout: Optional[float] = None) -> List[Dict[str, Any]]:
|
|
219
|
+
"""The tools this address offers, with their input and output schemas."""
|
|
220
|
+
tools: List[Dict[str, Any]] = []
|
|
221
|
+
cursor: Optional[str] = None
|
|
222
|
+
while True:
|
|
223
|
+
res = (await self._rpc("tools/list", {"cursor": cursor} if cursor else {}, timeout, True)).get("result") or {}
|
|
224
|
+
tools.extend(res.get("tools") or [])
|
|
225
|
+
cursor = res.get("nextCursor")
|
|
226
|
+
if not cursor:
|
|
227
|
+
return tools
|
|
228
|
+
|
|
229
|
+
async def aclose(self) -> None:
|
|
230
|
+
"""Nothing to release; here for the async with-statement."""
|
|
231
|
+
|
|
232
|
+
async def __aenter__(self) -> "AsyncAlion":
|
|
233
|
+
return self
|
|
234
|
+
|
|
235
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
236
|
+
await self.aclose()
|
|
237
|
+
|
|
238
|
+
async def _rpc(self, method: str, params: Dict[str, Any], timeout: Optional[float], retry_5xx: bool) -> Dict[str, Any]:
|
|
239
|
+
headers, body = self._build(method, params)
|
|
240
|
+
loop = asyncio.get_running_loop()
|
|
241
|
+
attempt = 0
|
|
242
|
+
while True:
|
|
243
|
+
try:
|
|
244
|
+
# (the blocking transport runs in a worker thread: no event-loop stall, no extra dependency)
|
|
245
|
+
status, h, text = await loop.run_in_executor(None, functools.partial(self._transport, self.endpoint, headers, body, timeout or self.timeout))
|
|
246
|
+
except AlionError:
|
|
247
|
+
raise
|
|
248
|
+
except Exception as e:
|
|
249
|
+
if self._is_timeout(e):
|
|
250
|
+
raise self._network_error(TimeoutError(str(e))) from e
|
|
251
|
+
if attempt < self.max_retries:
|
|
252
|
+
await asyncio.sleep(_backoff(attempt))
|
|
253
|
+
attempt += 1
|
|
254
|
+
continue
|
|
255
|
+
raise self._network_error(e) from e
|
|
256
|
+
if _retryable(status, retry_5xx) and attempt < self.max_retries:
|
|
257
|
+
ra = _retry_after(h)
|
|
258
|
+
await asyncio.sleep(ra if ra is not None else _backoff(attempt))
|
|
259
|
+
attempt += 1
|
|
260
|
+
continue
|
|
261
|
+
return self._parse(status, h, text)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _clean(args: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
|
|
265
|
+
return {k: v for k, v in (args or {}).items() if v is not None}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _tool_result(name: str, rpc: Dict[str, Any]) -> Any:
|
|
269
|
+
result = rpc.get("result") or {}
|
|
270
|
+
payload = result.get("structuredContent")
|
|
271
|
+
if payload is None:
|
|
272
|
+
payload = _text_content(result.get("content"))
|
|
273
|
+
if result.get("isError"):
|
|
274
|
+
raise AlionError._from_tool(name, payload)
|
|
275
|
+
return payload
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _text_content(content: Any) -> Any:
|
|
279
|
+
for c in content or []:
|
|
280
|
+
if isinstance(c, dict) and c.get("type") == "text" and isinstance(c.get("text"), str):
|
|
281
|
+
try:
|
|
282
|
+
return json.loads(c["text"])
|
|
283
|
+
except ValueError:
|
|
284
|
+
return c["text"]
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _parse_body(text: str, content_type: str) -> Any:
|
|
289
|
+
if not text:
|
|
290
|
+
return None
|
|
291
|
+
if "text/event-stream" in content_type:
|
|
292
|
+
lines = [l[5:].strip() for l in text.splitlines() if l.startswith("data:")]
|
|
293
|
+
text = lines[-1] if lines else ""
|
|
294
|
+
try:
|
|
295
|
+
return json.loads(text)
|
|
296
|
+
except ValueError:
|
|
297
|
+
return text
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _retryable(status: int, retry_5xx: bool) -> bool:
|
|
301
|
+
return status == 429 or (retry_5xx and status >= 500)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _backoff(attempt: int) -> float:
|
|
305
|
+
return min(8.0, 0.5 * (1 << min(attempt, 6))) + random.random() * 0.25
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _retry_after(headers: Mapping[str, str]) -> Optional[float]:
|
|
309
|
+
v = headers.get("retry-after")
|
|
310
|
+
if v is None:
|
|
311
|
+
return None
|
|
312
|
+
try:
|
|
313
|
+
return min(60.0, max(0.0, float(v)))
|
|
314
|
+
except ValueError:
|
|
315
|
+
return None
|
alion/_errors.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Mapping, Optional
|
|
4
|
+
|
|
5
|
+
_ACCOUNT_CODES = frozenset({"account_required", "payment_required", "auth_required", "unauthorized", "http_401", "http_402"})
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AlionError(Exception):
|
|
9
|
+
"""Every failure of a call: a refusal by a tool (the server's code, e.g. ``account_required``,
|
|
10
|
+
``rate_limited``, ``invalid_params``), an HTTP error, a JSON-RPC error, a timeout or a network error.
|
|
11
|
+
|
|
12
|
+
>>> try:
|
|
13
|
+
... alion.get_company(domain="stripe.com")
|
|
14
|
+
... except AlionError as e:
|
|
15
|
+
... if e.needs_account: ...
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
code: str
|
|
19
|
+
"""A machine-readable code: the server's error code, or timeout / network_error / http_<status> / rpc_<code>."""
|
|
20
|
+
status: Optional[int]
|
|
21
|
+
"""The HTTP status, when the server answered with an error status."""
|
|
22
|
+
tool: Optional[str]
|
|
23
|
+
"""The tool that refused, for a tool error."""
|
|
24
|
+
data: Any
|
|
25
|
+
"""The whole error payload from the server (hints, free_tools, examples, ...)."""
|
|
26
|
+
retry_after: Optional[float]
|
|
27
|
+
"""Seconds to wait before retrying, when the server said so."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, message: str, *, code: str, status: Optional[int] = None, tool: Optional[str] = None, data: Any = None, retry_after: Optional[float] = None) -> None:
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.message = message
|
|
32
|
+
self.code = code
|
|
33
|
+
self.status = status
|
|
34
|
+
self.tool = tool
|
|
35
|
+
self.data = data
|
|
36
|
+
self.retry_after = retry_after
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def needs_account(self) -> bool:
|
|
40
|
+
"""True when the tool needs an API key (or a payment): it is not free on the open address."""
|
|
41
|
+
return self.code in _ACCOUNT_CODES
|
|
42
|
+
|
|
43
|
+
def __repr__(self) -> str:
|
|
44
|
+
return f"AlionError(code={self.code!r}, status={self.status!r}, message={self.message!r})"
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def _from_tool(cls, tool: str, payload: Any) -> "AlionError":
|
|
48
|
+
p: Dict[str, Any] = payload if isinstance(payload, dict) else {"message": "" if payload is None else str(payload)}
|
|
49
|
+
code = p.get("error") if isinstance(p.get("error"), str) else p.get("code") if isinstance(p.get("code"), str) else "tool_error"
|
|
50
|
+
msg = p.get("message") if isinstance(p.get("message"), str) else p.get("error") if isinstance(p.get("error"), str) else f"{tool} failed"
|
|
51
|
+
ra = p.get("retry_after")
|
|
52
|
+
return cls(f"{tool}: {msg}", code=str(code), tool=tool, data=payload, retry_after=float(ra) if isinstance(ra, (int, float)) else None)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def _from_rpc(cls, err: Mapping[str, Any]) -> "AlionError":
|
|
56
|
+
return cls(str(err.get("message") or f"JSON-RPC error {err.get('code')}"), code=f"rpc_{err.get('code')}", data=err.get("data", err))
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def _from_http(cls, status: int, data: Any, headers: Mapping[str, str]) -> "AlionError":
|
|
60
|
+
retry_after: Optional[float] = None
|
|
61
|
+
try:
|
|
62
|
+
v = float(headers.get("retry-after", ""))
|
|
63
|
+
retry_after = v if v > 0 else None
|
|
64
|
+
except ValueError:
|
|
65
|
+
pass
|
|
66
|
+
if isinstance(data, dict):
|
|
67
|
+
rpc = data.get("error") if isinstance(data.get("error"), dict) else None
|
|
68
|
+
if rpc is not None:
|
|
69
|
+
return cls(str(rpc.get("message") or f"HTTP {status}"), code=_code_for(status, rpc.get("code")), status=status, data=rpc.get("data", data), retry_after=retry_after)
|
|
70
|
+
code = data["error"] if isinstance(data.get("error"), str) else f"http_{status}"
|
|
71
|
+
msg = data.get("message") if isinstance(data.get("message"), str) else data.get("detail") if isinstance(data.get("detail"), str) else f"HTTP {status}"
|
|
72
|
+
return cls(str(msg), code=code, status=status, data=data, retry_after=retry_after)
|
|
73
|
+
text = f": {data}" if isinstance(data, str) and len(data) < 300 and data else ""
|
|
74
|
+
return cls(f"HTTP {status}{text}", code=f"http_{status}", status=status, data=data, retry_after=retry_after)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _code_for(status: int, rpc_code: Any) -> str:
|
|
78
|
+
if status == 401:
|
|
79
|
+
return "unauthorized"
|
|
80
|
+
if status == 402:
|
|
81
|
+
return "payment_required"
|
|
82
|
+
if status == 429:
|
|
83
|
+
return "rate_limited"
|
|
84
|
+
return f"rpc_{rpc_code}" if rpc_code is not None else f"http_{status}"
|