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.
@@ -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)