allternit-platform 0.1.0__tar.gz
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.
- allternit_platform-0.1.0/PKG-INFO +92 -0
- allternit_platform-0.1.0/README.md +83 -0
- allternit_platform-0.1.0/allternit_platform/__init__.py +28 -0
- allternit_platform-0.1.0/allternit_platform/_client.py +111 -0
- allternit_platform-0.1.0/allternit_platform/_core.py +187 -0
- allternit_platform-0.1.0/allternit_platform/_errors.py +129 -0
- allternit_platform-0.1.0/allternit_platform/_generated.py +1252 -0
- allternit_platform-0.1.0/allternit_platform/py.typed +0 -0
- allternit_platform-0.1.0/allternit_platform.egg-info/PKG-INFO +92 -0
- allternit_platform-0.1.0/allternit_platform.egg-info/SOURCES.txt +13 -0
- allternit_platform-0.1.0/allternit_platform.egg-info/dependency_links.txt +1 -0
- allternit_platform-0.1.0/allternit_platform.egg-info/top_level.txt +1 -0
- allternit_platform-0.1.0/pyproject.toml +21 -0
- allternit_platform-0.1.0/setup.cfg +4 -0
- allternit_platform-0.1.0/tests/test_client.py +216 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: allternit-platform
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the Allternit Platform API (agents, conversations, numbers, messaging, webhooks, usage).
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Project-URL: Documentation, https://docs.allternit.com/api/platform/sdks/python
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# allternit-platform
|
|
11
|
+
|
|
12
|
+
Python SDK for the [Allternit Platform API](https://docs.allternit.com/api/platform/overview): agents, conversations, numbers, messaging, webhooks and usage.
|
|
13
|
+
|
|
14
|
+
> **Not yet published to PyPI.** Publishing needs Eoj's OK. Until then, install it from this repo (see below).
|
|
15
|
+
|
|
16
|
+
- Python 3.9+, standard library only.
|
|
17
|
+
- Typed responses (`TypedDict`s) and one method per API operation, generated from `cmd/allternit-cloud-api/openapi/platform-v1.yaml`.
|
|
18
|
+
- Sends an `Idempotency-Key` on every POST (random unless you pass one).
|
|
19
|
+
- Errors are typed exceptions (`NotFoundError`, `RateLimitError`, …).
|
|
20
|
+
- Cursor pagination helpers and streaming replies.
|
|
21
|
+
|
|
22
|
+
## Install (from the repo, until it is published)
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install /path/to/allternit/sdk/platform-python
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quickstart
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from allternit_platform import AllternitPlatform, MessageDelta
|
|
32
|
+
|
|
33
|
+
client = AllternitPlatform() # reads ALLTERNIT_API_KEY (alt_test_... or alt_live_...)
|
|
34
|
+
|
|
35
|
+
account = client.accounts.create(name="Lakeside Dental")
|
|
36
|
+
agent = client.agents.create(
|
|
37
|
+
account_id=account["id"],
|
|
38
|
+
name="Front desk",
|
|
39
|
+
instructions="Answer questions about Lakeside Dental and book cleanings.",
|
|
40
|
+
)
|
|
41
|
+
conversation = client.conversations.create(agent["id"])
|
|
42
|
+
|
|
43
|
+
# Whole reply
|
|
44
|
+
reply = client.conversations.send_message(conversation["id"], content="Are you open Saturday?")
|
|
45
|
+
print(reply["content"])
|
|
46
|
+
|
|
47
|
+
# Streamed reply
|
|
48
|
+
stream = client.conversations.stream(conversation["id"], content="Can I book a cleaning?")
|
|
49
|
+
for event in stream:
|
|
50
|
+
if isinstance(event, MessageDelta):
|
|
51
|
+
print(event.delta, end="", flush=True)
|
|
52
|
+
print(stream.final_message["id"])
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Errors
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from allternit_platform import APIError, NotFoundError, RateLimitError
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
client.agents.get("agent_missing")
|
|
62
|
+
except NotFoundError as e:
|
|
63
|
+
print(e.code) # "agent_not_found"
|
|
64
|
+
except RateLimitError as e:
|
|
65
|
+
print(f"retry in {e.retry_after}s")
|
|
66
|
+
except APIError as e:
|
|
67
|
+
print(e.status, e.type, e.code, e.param, e.request_id)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`503` with `code == "runtime_starting"` (an `InternalServerError`) means the project's hosted runtime is starting; retry after a few seconds.
|
|
71
|
+
|
|
72
|
+
## Pagination
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
page = client.agents.list(limit=50) # {"data": [...], "has_more": ..., "next_cursor": ...}
|
|
76
|
+
for agent in client.agents.list_all(): # every agent, page by page
|
|
77
|
+
...
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Options
|
|
81
|
+
|
|
82
|
+
`AllternitPlatform(api_key=None, *, base_url=None, timeout=60.0, default_headers=None)`. Every method takes `timeout=` and `extra_headers=`; POST methods also take `idempotency_key=`. Parameters whose names are Python keywords get a trailing underscore (`from_=`).
|
|
83
|
+
|
|
84
|
+
## Regenerate and test
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
python3 scripts/platform-sdk/generate.py # from the repo root, after editing platform-v1.yaml
|
|
88
|
+
python3 scripts/platform-sdk/generate.py --check # fails if the generated code is stale
|
|
89
|
+
cd sdk/platform-python && python3 -m unittest discover -s tests
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`allternit_platform/_generated.py` is generated; edit the other modules by hand.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# allternit-platform
|
|
2
|
+
|
|
3
|
+
Python SDK for the [Allternit Platform API](https://docs.allternit.com/api/platform/overview): agents, conversations, numbers, messaging, webhooks and usage.
|
|
4
|
+
|
|
5
|
+
> **Not yet published to PyPI.** Publishing needs Eoj's OK. Until then, install it from this repo (see below).
|
|
6
|
+
|
|
7
|
+
- Python 3.9+, standard library only.
|
|
8
|
+
- Typed responses (`TypedDict`s) and one method per API operation, generated from `cmd/allternit-cloud-api/openapi/platform-v1.yaml`.
|
|
9
|
+
- Sends an `Idempotency-Key` on every POST (random unless you pass one).
|
|
10
|
+
- Errors are typed exceptions (`NotFoundError`, `RateLimitError`, …).
|
|
11
|
+
- Cursor pagination helpers and streaming replies.
|
|
12
|
+
|
|
13
|
+
## Install (from the repo, until it is published)
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install /path/to/allternit/sdk/platform-python
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from allternit_platform import AllternitPlatform, MessageDelta
|
|
23
|
+
|
|
24
|
+
client = AllternitPlatform() # reads ALLTERNIT_API_KEY (alt_test_... or alt_live_...)
|
|
25
|
+
|
|
26
|
+
account = client.accounts.create(name="Lakeside Dental")
|
|
27
|
+
agent = client.agents.create(
|
|
28
|
+
account_id=account["id"],
|
|
29
|
+
name="Front desk",
|
|
30
|
+
instructions="Answer questions about Lakeside Dental and book cleanings.",
|
|
31
|
+
)
|
|
32
|
+
conversation = client.conversations.create(agent["id"])
|
|
33
|
+
|
|
34
|
+
# Whole reply
|
|
35
|
+
reply = client.conversations.send_message(conversation["id"], content="Are you open Saturday?")
|
|
36
|
+
print(reply["content"])
|
|
37
|
+
|
|
38
|
+
# Streamed reply
|
|
39
|
+
stream = client.conversations.stream(conversation["id"], content="Can I book a cleaning?")
|
|
40
|
+
for event in stream:
|
|
41
|
+
if isinstance(event, MessageDelta):
|
|
42
|
+
print(event.delta, end="", flush=True)
|
|
43
|
+
print(stream.final_message["id"])
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Errors
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from allternit_platform import APIError, NotFoundError, RateLimitError
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
client.agents.get("agent_missing")
|
|
53
|
+
except NotFoundError as e:
|
|
54
|
+
print(e.code) # "agent_not_found"
|
|
55
|
+
except RateLimitError as e:
|
|
56
|
+
print(f"retry in {e.retry_after}s")
|
|
57
|
+
except APIError as e:
|
|
58
|
+
print(e.status, e.type, e.code, e.param, e.request_id)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`503` with `code == "runtime_starting"` (an `InternalServerError`) means the project's hosted runtime is starting; retry after a few seconds.
|
|
62
|
+
|
|
63
|
+
## Pagination
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
page = client.agents.list(limit=50) # {"data": [...], "has_more": ..., "next_cursor": ...}
|
|
67
|
+
for agent in client.agents.list_all(): # every agent, page by page
|
|
68
|
+
...
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Options
|
|
72
|
+
|
|
73
|
+
`AllternitPlatform(api_key=None, *, base_url=None, timeout=60.0, default_headers=None)`. Every method takes `timeout=` and `extra_headers=`; POST methods also take `idempotency_key=`. Parameters whose names are Python keywords get a trailing underscore (`from_=`).
|
|
74
|
+
|
|
75
|
+
## Regenerate and test
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
python3 scripts/platform-sdk/generate.py # from the repo root, after editing platform-v1.yaml
|
|
79
|
+
python3 scripts/platform-sdk/generate.py --check # fails if the generated code is stale
|
|
80
|
+
cd sdk/platform-python && python3 -m unittest discover -s tests
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`allternit_platform/_generated.py` is generated; edit the other modules by hand.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Python SDK for the Allternit Platform API."""
|
|
2
|
+
|
|
3
|
+
from ._client import AllternitPlatform, Conversations, ConversationStream, MessageCompleted, MessageDelta, StreamEvent
|
|
4
|
+
from ._core import DEFAULT_BASE_URL, SSEEvent, Transport, parse_sse
|
|
5
|
+
from ._errors import (
|
|
6
|
+
AllternitError,
|
|
7
|
+
APIConnectionError,
|
|
8
|
+
APIError,
|
|
9
|
+
APITimeoutError,
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
ConflictError,
|
|
12
|
+
InternalServerError,
|
|
13
|
+
InvalidRequestError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
PaymentRequiredError,
|
|
16
|
+
PermissionDeniedError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
)
|
|
19
|
+
from ._generated import NOT_GIVEN
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"AllternitPlatform", "Conversations", "ConversationStream", "MessageCompleted", "MessageDelta", "StreamEvent",
|
|
25
|
+
"DEFAULT_BASE_URL", "SSEEvent", "Transport", "parse_sse", "NOT_GIVEN",
|
|
26
|
+
"AllternitError", "APIConnectionError", "APIError", "APITimeoutError", "AuthenticationError", "ConflictError",
|
|
27
|
+
"InternalServerError", "InvalidRequestError", "NotFoundError", "PaymentRequiredError", "PermissionDeniedError", "RateLimitError",
|
|
28
|
+
]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""The Allternit Platform API client.
|
|
2
|
+
|
|
3
|
+
from allternit_platform import AllternitPlatform
|
|
4
|
+
|
|
5
|
+
client = AllternitPlatform() # reads ALLTERNIT_API_KEY
|
|
6
|
+
agent = client.agents.create(account_id="acct_...", name="Front desk")
|
|
7
|
+
|
|
8
|
+
Resources (``client.agents``, ``client.conversations``, ...) are generated
|
|
9
|
+
from the OpenAPI file; this module adds the typed conversation stream.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Dict, Iterator, Optional, Union
|
|
16
|
+
|
|
17
|
+
from ._core import DEFAULT_TIMEOUT, SSEEvent, Transport
|
|
18
|
+
from ._errors import error_for
|
|
19
|
+
from ._generated import ConversationsResource, GeneratedResources
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class MessageDelta:
|
|
24
|
+
"""A piece of the agent's reply (``message.delta``)."""
|
|
25
|
+
|
|
26
|
+
delta: str
|
|
27
|
+
type: str = "message.delta"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class MessageCompleted:
|
|
32
|
+
"""The stored reply (``message.completed``)."""
|
|
33
|
+
|
|
34
|
+
message: Dict[str, Any]
|
|
35
|
+
type: str = "message.completed"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
StreamEvent = Union[MessageDelta, MessageCompleted]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConversationStream:
|
|
42
|
+
"""The agent's reply as it is written. Iterate it for ``MessageDelta`` and
|
|
43
|
+
``MessageCompleted`` events; an ``error`` event raises a typed ``APIError``.
|
|
44
|
+
``final_message`` is set once the stream finishes."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, source: Iterator[SSEEvent]) -> None:
|
|
47
|
+
self._source = source
|
|
48
|
+
self._started = False
|
|
49
|
+
self.final_message: Optional[Dict[str, Any]] = None
|
|
50
|
+
self.text = ""
|
|
51
|
+
|
|
52
|
+
def __iter__(self) -> Iterator[StreamEvent]:
|
|
53
|
+
if self._started:
|
|
54
|
+
raise RuntimeError("A ConversationStream can only be read once.")
|
|
55
|
+
self._started = True
|
|
56
|
+
for ev in self._source:
|
|
57
|
+
if ev.event == "message.delta":
|
|
58
|
+
delta = str((ev.data or {}).get("delta", "")) if isinstance(ev.data, dict) else str(ev.data)
|
|
59
|
+
self.text += delta
|
|
60
|
+
yield MessageDelta(delta)
|
|
61
|
+
elif ev.event == "message.completed":
|
|
62
|
+
self.final_message = ev.data
|
|
63
|
+
yield MessageCompleted(ev.data)
|
|
64
|
+
elif ev.event == "error":
|
|
65
|
+
body = ev.data.get("error") if isinstance(ev.data, dict) else None
|
|
66
|
+
raise error_for(0, body or {"message": str(ev.data)}, None, "The stream reported an error.")
|
|
67
|
+
|
|
68
|
+
def until_done(self) -> Dict[str, Any]:
|
|
69
|
+
"""Read the whole stream and return the stored reply."""
|
|
70
|
+
if not self._started:
|
|
71
|
+
for _ in self:
|
|
72
|
+
pass
|
|
73
|
+
if self.final_message is None:
|
|
74
|
+
raise RuntimeError("The stream ended without a message.completed event.")
|
|
75
|
+
return self.final_message
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Conversations(ConversationsResource):
|
|
79
|
+
def stream(
|
|
80
|
+
self,
|
|
81
|
+
id: str,
|
|
82
|
+
*,
|
|
83
|
+
content: str,
|
|
84
|
+
idempotency_key: Optional[str] = None,
|
|
85
|
+
timeout: Optional[float] = None,
|
|
86
|
+
extra_headers: Optional[Dict[str, str]] = None,
|
|
87
|
+
) -> ConversationStream:
|
|
88
|
+
"""Send a message and stream the agent's reply
|
|
89
|
+
(``POST /v1/conversations/{id}/messages`` with ``stream: true``)."""
|
|
90
|
+
return ConversationStream(
|
|
91
|
+
self.send_message_stream(id, content=content, idempotency_key=idempotency_key,
|
|
92
|
+
timeout=timeout, extra_headers=extra_headers)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class AllternitPlatform(GeneratedResources):
|
|
97
|
+
"""Client for https://api.allternit.com. Every API area is an attribute."""
|
|
98
|
+
|
|
99
|
+
conversations: Conversations
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
api_key: Optional[str] = None,
|
|
104
|
+
*,
|
|
105
|
+
base_url: Optional[str] = None,
|
|
106
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
107
|
+
default_headers: Optional[Dict[str, str]] = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
self.http = Transport(api_key, base_url=base_url, timeout=timeout, default_headers=default_headers)
|
|
110
|
+
super().__init__(self.http)
|
|
111
|
+
self.conversations = Conversations(self.http)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""HTTP transport shared by every generated resource: auth, Idempotency-Key,
|
|
2
|
+
timeouts, error mapping, cursor pagination and server-sent events.
|
|
3
|
+
Standard library only."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import socket
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Callable, Dict, Iterator, Optional
|
|
13
|
+
from urllib.error import HTTPError, URLError
|
|
14
|
+
from urllib.parse import urlencode
|
|
15
|
+
from urllib.request import Request, urlopen
|
|
16
|
+
|
|
17
|
+
from ._errors import APIConnectionError, APITimeoutError, error_for
|
|
18
|
+
|
|
19
|
+
DEFAULT_BASE_URL = "https://api.allternit.com"
|
|
20
|
+
DEFAULT_TIMEOUT = 60.0
|
|
21
|
+
|
|
22
|
+
Page = Dict[str, Any]
|
|
23
|
+
"""A list response: ``{"data": [...], "has_more": bool, "next_cursor": str | None}``."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class SSEEvent:
|
|
28
|
+
"""One server-sent event: its ``event:`` name and its ``data:`` payload, JSON-parsed when possible."""
|
|
29
|
+
|
|
30
|
+
event: str
|
|
31
|
+
data: Any
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Transport:
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
api_key: Optional[str] = None,
|
|
38
|
+
*,
|
|
39
|
+
base_url: Optional[str] = None,
|
|
40
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
41
|
+
default_headers: Optional[Dict[str, str]] = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
api_key = api_key or os.environ.get("ALLTERNIT_API_KEY")
|
|
44
|
+
if not api_key:
|
|
45
|
+
raise ValueError("No API key. Pass api_key=... or set ALLTERNIT_API_KEY (alt_test_... or alt_live_...).")
|
|
46
|
+
self.api_key = api_key
|
|
47
|
+
self.base_url = (base_url or os.environ.get("ALLTERNIT_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
48
|
+
self.timeout = timeout
|
|
49
|
+
self.default_headers = dict(default_headers or {})
|
|
50
|
+
|
|
51
|
+
# -- plumbing ---------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def _open(
|
|
54
|
+
self,
|
|
55
|
+
method: str,
|
|
56
|
+
path: str,
|
|
57
|
+
*,
|
|
58
|
+
query: Optional[Dict[str, Any]],
|
|
59
|
+
body: Any,
|
|
60
|
+
accept: str,
|
|
61
|
+
idempotency_key: Optional[str],
|
|
62
|
+
timeout: Optional[float],
|
|
63
|
+
extra_headers: Optional[Dict[str, str]],
|
|
64
|
+
):
|
|
65
|
+
url = self.base_url + path
|
|
66
|
+
if query:
|
|
67
|
+
pairs = []
|
|
68
|
+
for k, v in query.items():
|
|
69
|
+
if v is None:
|
|
70
|
+
continue
|
|
71
|
+
for item in v if isinstance(v, (list, tuple)) else [v]:
|
|
72
|
+
pairs.append((k, "true" if item is True else "false" if item is False else str(item)))
|
|
73
|
+
if pairs:
|
|
74
|
+
url += "?" + urlencode(pairs)
|
|
75
|
+
headers = {
|
|
76
|
+
"Accept": accept,
|
|
77
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
78
|
+
"User-Agent": "allternit-platform-python/0.1.0",
|
|
79
|
+
**self.default_headers,
|
|
80
|
+
**(extra_headers or {}),
|
|
81
|
+
}
|
|
82
|
+
data = None
|
|
83
|
+
if body is not None and method != "GET":
|
|
84
|
+
headers["Content-Type"] = "application/json"
|
|
85
|
+
data = json.dumps(body).encode()
|
|
86
|
+
if method == "POST" and not any(h.lower() == "idempotency-key" for h in headers):
|
|
87
|
+
headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
|
|
88
|
+
req = Request(url, data=data, headers=headers, method=method)
|
|
89
|
+
wait = self.timeout if timeout is None else timeout
|
|
90
|
+
try:
|
|
91
|
+
return urlopen(req, timeout=wait)
|
|
92
|
+
except HTTPError as e:
|
|
93
|
+
raw = e.read().decode("utf-8", "replace")
|
|
94
|
+
try:
|
|
95
|
+
parsed = json.loads(raw) if raw else None
|
|
96
|
+
except ValueError:
|
|
97
|
+
parsed = None
|
|
98
|
+
err = parsed.get("error") if isinstance(parsed, dict) and isinstance(parsed.get("error"), dict) else None
|
|
99
|
+
raise error_for(e.code, err, dict(e.headers.items()), raw[:200] or None) from None
|
|
100
|
+
except (socket.timeout, TimeoutError) as e:
|
|
101
|
+
raise APITimeoutError(f"Request timed out after {wait} s.") from e
|
|
102
|
+
except URLError as e:
|
|
103
|
+
if isinstance(e.reason, (socket.timeout, TimeoutError)):
|
|
104
|
+
raise APITimeoutError(f"Request timed out after {wait} s.") from e
|
|
105
|
+
raise APIConnectionError(f"Could not reach {self.base_url}: {e.reason}") from e
|
|
106
|
+
|
|
107
|
+
# -- what generated resources call ------------------------------------
|
|
108
|
+
|
|
109
|
+
def request(
|
|
110
|
+
self,
|
|
111
|
+
method: str,
|
|
112
|
+
path: str,
|
|
113
|
+
*,
|
|
114
|
+
query: Optional[Dict[str, Any]] = None,
|
|
115
|
+
body: Any = None,
|
|
116
|
+
idempotency_key: Optional[str] = None,
|
|
117
|
+
timeout: Optional[float] = None,
|
|
118
|
+
extra_headers: Optional[Dict[str, str]] = None,
|
|
119
|
+
) -> Any:
|
|
120
|
+
resp = self._open(method, path, query=query, body=body, accept="application/json",
|
|
121
|
+
idempotency_key=idempotency_key, timeout=timeout, extra_headers=extra_headers)
|
|
122
|
+
with resp:
|
|
123
|
+
raw = resp.read()
|
|
124
|
+
return json.loads(raw) if raw else None
|
|
125
|
+
|
|
126
|
+
def stream_request(
|
|
127
|
+
self,
|
|
128
|
+
method: str,
|
|
129
|
+
path: str,
|
|
130
|
+
*,
|
|
131
|
+
query: Optional[Dict[str, Any]] = None,
|
|
132
|
+
body: Any = None,
|
|
133
|
+
idempotency_key: Optional[str] = None,
|
|
134
|
+
timeout: Optional[float] = None,
|
|
135
|
+
extra_headers: Optional[Dict[str, str]] = None,
|
|
136
|
+
) -> Iterator[SSEEvent]:
|
|
137
|
+
resp = self._open(method, path, query=query, body=body, accept="text/event-stream",
|
|
138
|
+
idempotency_key=idempotency_key, timeout=timeout, extra_headers=extra_headers)
|
|
139
|
+
|
|
140
|
+
def events() -> Iterator[SSEEvent]:
|
|
141
|
+
with resp:
|
|
142
|
+
yield from parse_sse(iter(resp.readline, b""))
|
|
143
|
+
|
|
144
|
+
return events()
|
|
145
|
+
|
|
146
|
+
def paginate(self, fetch_page: Callable[[Optional[str]], Page], after: Optional[str] = None) -> Iterator[Any]:
|
|
147
|
+
cursor = after
|
|
148
|
+
while True:
|
|
149
|
+
page = fetch_page(cursor)
|
|
150
|
+
for item in page.get("data") or []:
|
|
151
|
+
yield item
|
|
152
|
+
if not page.get("has_more") or not page.get("next_cursor"):
|
|
153
|
+
return
|
|
154
|
+
cursor = page["next_cursor"]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def parse_sse(lines: Iterator[bytes]) -> Iterator[SSEEvent]:
|
|
158
|
+
"""Parse ``text/event-stream`` lines (bytes, newline-terminated) into events."""
|
|
159
|
+
event, data = "message", []
|
|
160
|
+
for raw in lines:
|
|
161
|
+
line = raw.decode("utf-8").rstrip("\r\n")
|
|
162
|
+
if line == "":
|
|
163
|
+
if data:
|
|
164
|
+
payload = "\n".join(data)
|
|
165
|
+
try:
|
|
166
|
+
parsed: Any = json.loads(payload)
|
|
167
|
+
except ValueError:
|
|
168
|
+
parsed = payload
|
|
169
|
+
yield SSEEvent(event, parsed)
|
|
170
|
+
event, data = "message", []
|
|
171
|
+
continue
|
|
172
|
+
if line.startswith(":"):
|
|
173
|
+
continue
|
|
174
|
+
field, _, value = line.partition(":")
|
|
175
|
+
if value.startswith(" "):
|
|
176
|
+
value = value[1:]
|
|
177
|
+
if field == "event":
|
|
178
|
+
event = value
|
|
179
|
+
elif field == "data":
|
|
180
|
+
data.append(value)
|
|
181
|
+
if data:
|
|
182
|
+
payload = "\n".join(data)
|
|
183
|
+
try:
|
|
184
|
+
parsed = json.loads(payload)
|
|
185
|
+
except ValueError:
|
|
186
|
+
parsed = payload
|
|
187
|
+
yield SSEEvent(event, parsed)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Typed errors. Every non-2xx answer becomes an ``APIError`` subclass picked
|
|
2
|
+
by HTTP status, carrying the API's ``{"error": {type, code, message, param}}``."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any, Mapping, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AllternitError(Exception):
|
|
10
|
+
"""Base class for every error this SDK raises."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class APIError(AllternitError):
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
status: int,
|
|
17
|
+
body: Optional[Mapping[str, Any]] = None,
|
|
18
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
19
|
+
fallback: Optional[str] = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
body = body or {}
|
|
22
|
+
self.status = status
|
|
23
|
+
self.type: Optional[str] = body.get("type")
|
|
24
|
+
self.code: Optional[str] = body.get("code")
|
|
25
|
+
self.param: Optional[str] = body.get("param")
|
|
26
|
+
#: Where to fix it (402 ``payment_method_required``: the console billing page).
|
|
27
|
+
self.url: Optional[str] = body.get("url")
|
|
28
|
+
self.headers = dict(headers or {})
|
|
29
|
+
lower = {k.lower(): v for k, v in self.headers.items()}
|
|
30
|
+
self.request_id: Optional[str] = lower.get("x-request-id")
|
|
31
|
+
self.message: str = body.get("message") or fallback or f"HTTP {status}"
|
|
32
|
+
super().__init__(self.message)
|
|
33
|
+
|
|
34
|
+
def __repr__(self) -> str:
|
|
35
|
+
return f"{type(self).__name__}(status={self.status}, code={self.code!r}, message={self.message!r})"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class InvalidRequestError(APIError):
|
|
39
|
+
"""400 / 422: the request was malformed or a field is invalid."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AuthenticationError(APIError):
|
|
43
|
+
"""401: missing, invalid or revoked API key."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PermissionDeniedError(APIError):
|
|
47
|
+
"""403: the key lacks a scope, or is bound to another account."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Alias matching the API's `permission_error` type. Not the builtin PermissionError.
|
|
51
|
+
PermissionError = PermissionDeniedError # noqa: A001
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class NotFoundError(APIError):
|
|
55
|
+
"""404: no such resource (or it belongs to another project)."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PaymentRequiredError(APIError):
|
|
59
|
+
"""402 ``billing_error``: ``payment_method_required`` (no card on file; open
|
|
60
|
+
``url``, the console billing page) or ``spend_cap_reached``."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ConflictError(APIError):
|
|
64
|
+
"""409: e.g. ``conversation_busy`` or an idempotency key reused with a different request."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RateLimitError(APIError):
|
|
68
|
+
"""429: rate limited. ``retry_after`` is in seconds when the server sent ``Retry-After``."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
71
|
+
super().__init__(*args, **kwargs)
|
|
72
|
+
raw = {k.lower(): v for k, v in self.headers.items()}.get("retry-after")
|
|
73
|
+
try:
|
|
74
|
+
self.retry_after: Optional[float] = float(raw) if raw is not None else None
|
|
75
|
+
except ValueError:
|
|
76
|
+
self.retry_after = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class InternalServerError(APIError):
|
|
80
|
+
"""5xx: the API failed, or (``code == "runtime_starting"``) asks you to retry shortly."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class APIConnectionError(AllternitError):
|
|
84
|
+
"""The request never got an HTTP answer (DNS, refused, reset)."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class APITimeoutError(APIConnectionError):
|
|
88
|
+
"""The request took longer than ``timeout``."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
_BY_TYPE = {
|
|
92
|
+
"invalid_request_error": InvalidRequestError,
|
|
93
|
+
"authentication_error": AuthenticationError,
|
|
94
|
+
"billing_error": PaymentRequiredError,
|
|
95
|
+
"permission_error": PermissionDeniedError,
|
|
96
|
+
"not_found_error": NotFoundError,
|
|
97
|
+
"conflict_error": ConflictError,
|
|
98
|
+
"rate_limit_error": RateLimitError,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def error_for(
|
|
103
|
+
status: int,
|
|
104
|
+
body: Optional[Mapping[str, Any]],
|
|
105
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
106
|
+
fallback: Optional[str] = None,
|
|
107
|
+
) -> APIError:
|
|
108
|
+
"""The right error class for a status (0 = an ``error`` event inside a stream)."""
|
|
109
|
+
if status in (400, 422):
|
|
110
|
+
cls: type = InvalidRequestError
|
|
111
|
+
elif status == 401:
|
|
112
|
+
cls = AuthenticationError
|
|
113
|
+
elif status == 402:
|
|
114
|
+
cls = PaymentRequiredError
|
|
115
|
+
elif status == 403:
|
|
116
|
+
cls = PermissionDeniedError
|
|
117
|
+
elif status == 404:
|
|
118
|
+
cls = NotFoundError
|
|
119
|
+
elif status == 409:
|
|
120
|
+
cls = ConflictError
|
|
121
|
+
elif status == 429:
|
|
122
|
+
cls = RateLimitError
|
|
123
|
+
elif status >= 500:
|
|
124
|
+
cls = InternalServerError
|
|
125
|
+
elif status == 0:
|
|
126
|
+
cls = _BY_TYPE.get((body or {}).get("type") or "", APIError)
|
|
127
|
+
else:
|
|
128
|
+
cls = APIError
|
|
129
|
+
return cls(status, body, headers, fallback)
|