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