callaider 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.
callaider/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ from callaider._client import AsyncCallaider, Callaider
2
+ from callaider.exceptions import (
3
+ APIConnectionError,
4
+ APIError,
5
+ APIStatusError,
6
+ AuthenticationError,
7
+ CallaiderError,
8
+ InternalServerError,
9
+ NotFoundError,
10
+ RateLimitError,
11
+ )
12
+ from callaider.webhooks import WebhookParsingError, Webhooks
13
+
14
+ __all__ = [
15
+ "Callaider",
16
+ "AsyncCallaider",
17
+ "Webhooks",
18
+ "WebhookParsingError",
19
+ "CallaiderError",
20
+ "APIError",
21
+ "APIStatusError",
22
+ "AuthenticationError",
23
+ "NotFoundError",
24
+ "RateLimitError",
25
+ "InternalServerError",
26
+ "APIConnectionError",
27
+ ]
@@ -0,0 +1,216 @@
1
+ import asyncio
2
+ import logging
3
+ import time
4
+ from typing import Any, Mapping
5
+ import httpx
6
+
7
+ from callaider._constants import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, RAW_USER_AGENT
8
+ from callaider.exceptions import (
9
+ APIConnectionError,
10
+ APIStatusError,
11
+ AuthenticationError,
12
+ InternalServerError,
13
+ NotFoundError,
14
+ RateLimitError,
15
+ )
16
+
17
+ logger = logging.getLogger("callaider")
18
+
19
+
20
+ def _make_status_error(
21
+ response: httpx.Response,
22
+ body: Any,
23
+ ) -> APIStatusError:
24
+ """Map HTTP status codes to specific exception types."""
25
+ status_code = response.status_code
26
+ message = f"Error code: {status_code}"
27
+ if isinstance(body, dict):
28
+ message = body.get("message") or body.get("detail") or str(body)
29
+ elif isinstance(body, str) and body:
30
+ message = body
31
+
32
+ if status_code in (401, 403):
33
+ return AuthenticationError(message, response=response, body=body)
34
+ if status_code == 404:
35
+ return NotFoundError(message, response=response, body=body)
36
+ if status_code == 429:
37
+ return RateLimitError(message, response=response, body=body)
38
+ if status_code >= 500:
39
+ return InternalServerError(message, response=response, body=body)
40
+ return APIStatusError(message, response=response, body=body)
41
+
42
+
43
+ class SyncHttpxClientWrapper:
44
+ """Synchronous HTTP transport layer with automatic retry logic."""
45
+
46
+ def __init__(
47
+ self,
48
+ base_url: str = DEFAULT_BASE_URL,
49
+ api_key: str | None = None,
50
+ timeout: float = DEFAULT_TIMEOUT,
51
+ max_retries: int = DEFAULT_MAX_RETRIES,
52
+ custom_headers: Mapping[str, str] | None = None,
53
+ ) -> None:
54
+ self.base_url = base_url.rstrip("/")
55
+ self.max_retries = max_retries
56
+
57
+ headers = {
58
+ "Authorization": f"Bearer {api_key}",
59
+ "User-Agent": RAW_USER_AGENT,
60
+ "Accept": "application/json",
61
+ **(custom_headers or {}),
62
+ }
63
+ self._client = httpx.Client(
64
+ base_url=self.base_url,
65
+ headers=headers,
66
+ timeout=timeout,
67
+ )
68
+
69
+ def request(
70
+ self,
71
+ method: str,
72
+ path: str,
73
+ *,
74
+ params: Mapping[str, Any] | None = None,
75
+ json_data: Any | None = None,
76
+ headers: Mapping[str, str] | None = None,
77
+ ) -> Any:
78
+ """Execute a synchronous HTTP request with exponential backoff retries."""
79
+ retries = self.max_retries
80
+ delay = 0.5
81
+
82
+ for attempt in range(retries + 1):
83
+ try:
84
+ response = self._client.request(
85
+ method=method,
86
+ url=path,
87
+ params=params,
88
+ json=json_data,
89
+ headers=headers,
90
+ )
91
+ if response.is_success:
92
+ return response.json() if response.content else None
93
+
94
+ # Attempt to parse response body for error messaging
95
+ try:
96
+ body = response.json()
97
+ except Exception:
98
+ body = response.text
99
+
100
+ # Retry on rate limits, timeouts, and server errors
101
+ if response.status_code in (408, 429, 500, 502, 503, 504) and attempt < retries:
102
+ logger.warning(
103
+ "Retrying request (%d/%d) due to status %d...",
104
+ attempt + 1,
105
+ retries,
106
+ response.status_code,
107
+ )
108
+ time.sleep(delay)
109
+ delay *= 2
110
+ continue
111
+
112
+ raise _make_status_error(response, body)
113
+
114
+ except httpx.RequestError as exc:
115
+ if attempt < retries:
116
+ logger.warning(
117
+ "Retrying request (%d/%d) due to network error: %s",
118
+ attempt + 1,
119
+ retries,
120
+ exc,
121
+ )
122
+ time.sleep(delay)
123
+ delay *= 2
124
+ continue
125
+ raise APIConnectionError(f"Connection error: {exc}") from exc
126
+
127
+ def close(self) -> None:
128
+ """Close the underlying HTTP client session."""
129
+ self._client.close()
130
+
131
+
132
+ class AsyncHttpxClientWrapper:
133
+ """Asynchronous HTTP transport layer with automatic retry logic."""
134
+
135
+ def __init__(
136
+ self,
137
+ base_url: str = DEFAULT_BASE_URL,
138
+ api_key: str | None = None,
139
+ timeout: float = DEFAULT_TIMEOUT,
140
+ max_retries: int = DEFAULT_MAX_RETRIES,
141
+ custom_headers: Mapping[str, str] | None = None,
142
+ ) -> None:
143
+ self.base_url = base_url.rstrip("/")
144
+ self.max_retries = max_retries
145
+
146
+ headers = {
147
+ "Authorization": f"Bearer {api_key}",
148
+ "User-Agent": RAW_USER_AGENT,
149
+ "Accept": "application/json",
150
+ **(custom_headers or {}),
151
+ }
152
+ self._client = httpx.AsyncClient(
153
+ base_url=self.base_url,
154
+ headers=headers,
155
+ timeout=timeout,
156
+ )
157
+
158
+ async def request(
159
+ self,
160
+ method: str,
161
+ path: str,
162
+ *,
163
+ params: Mapping[str, Any] | None = None,
164
+ json_data: Any | None = None,
165
+ headers: Mapping[str, str] | None = None,
166
+ ) -> Any:
167
+ """Execute an asynchronous HTTP request with exponential backoff retries."""
168
+ retries = self.max_retries
169
+ delay = 0.5
170
+
171
+ for attempt in range(retries + 1):
172
+ try:
173
+ response = await self._client.request(
174
+ method=method,
175
+ url=path,
176
+ params=params,
177
+ json=json_data,
178
+ headers=headers,
179
+ )
180
+ if response.is_success:
181
+ return response.json() if response.content else None
182
+
183
+ try:
184
+ body = response.json()
185
+ except Exception:
186
+ body = response.text
187
+
188
+ if response.status_code in (408, 429, 500, 502, 503, 504) and attempt < retries:
189
+ logger.warning(
190
+ "Retrying request (%d/%d) due to status %d...",
191
+ attempt + 1,
192
+ retries,
193
+ response.status_code,
194
+ )
195
+ await asyncio.sleep(delay)
196
+ delay *= 2
197
+ continue
198
+
199
+ raise _make_status_error(response, body)
200
+
201
+ except httpx.RequestError as exc:
202
+ if attempt < retries:
203
+ logger.warning(
204
+ "Retrying request (%d/%d) due to network error: %s",
205
+ attempt + 1,
206
+ retries,
207
+ exc,
208
+ )
209
+ await asyncio.sleep(delay)
210
+ delay *= 2
211
+ continue
212
+ raise APIConnectionError(f"Connection error: {exc}") from exc
213
+
214
+ async def close(self) -> None:
215
+ """Close the underlying async HTTP client session."""
216
+ await self._client.aclose()
callaider/_client.py ADDED
@@ -0,0 +1,99 @@
1
+ import os
2
+ from typing import Mapping
3
+ from callaider._base_client import AsyncHttpxClientWrapper, SyncHttpxClientWrapper
4
+ from callaider.resources.assistants import AssistantsResource, AsyncAssistantsResource
5
+ from callaider._constants import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT
6
+ from callaider._models import HealthResponse
7
+ from callaider.resources.ringing import AsyncRingingResource, RingingResource
8
+
9
+
10
+ class Callaider:
11
+ """Synchronous client for the Callaider API."""
12
+
13
+ def __init__(
14
+ self,
15
+ *,
16
+ api_key: str | None = None,
17
+ base_url: str = DEFAULT_BASE_URL,
18
+ timeout: float = DEFAULT_TIMEOUT,
19
+ max_retries: int = DEFAULT_MAX_RETRIES,
20
+ default_headers: Mapping[str, str] | None = None,
21
+ ) -> None:
22
+ resolved_key = api_key or os.getenv("CALLAIDER_API_KEY")
23
+ if not resolved_key:
24
+ raise ValueError(
25
+ "Missing API key. Pass `api_key` explicitly or set the `CALLAIDER_API_KEY` environment variable."
26
+ )
27
+
28
+ self._transport = SyncHttpxClientWrapper(
29
+ base_url=base_url,
30
+ api_key=resolved_key,
31
+ timeout=timeout,
32
+ max_retries=max_retries,
33
+ custom_headers=default_headers,
34
+ )
35
+
36
+ # Resource namespaces
37
+ self.ringing = RingingResource(self)
38
+ self.assistants = AssistantsResource(self)
39
+
40
+ def health(self) -> HealthResponse:
41
+ """Check API service health status."""
42
+ data = self._transport.request("GET", "/v1/health")
43
+ return HealthResponse.model_validate(data or {})
44
+
45
+ def close(self) -> None:
46
+ """Close the active client session."""
47
+ self._transport.close()
48
+
49
+ def __enter__(self) -> "Callaider":
50
+ return self
51
+
52
+ def __exit__(self, *args: object) -> None:
53
+ self.close()
54
+
55
+
56
+ class AsyncCallaider:
57
+ """Asynchronous client for the Callaider API."""
58
+
59
+ def __init__(
60
+ self,
61
+ *,
62
+ api_key: str | None = None,
63
+ base_url: str = DEFAULT_BASE_URL,
64
+ timeout: float = DEFAULT_TIMEOUT,
65
+ max_retries: int = DEFAULT_MAX_RETRIES,
66
+ default_headers: Mapping[str, str] | None = None,
67
+ ) -> None:
68
+ resolved_key = api_key or os.getenv("CALLAIDER_API_KEY")
69
+ if not resolved_key:
70
+ raise ValueError(
71
+ "Missing API key. Pass `api_key` explicitly or set the `CALLAIDER_API_KEY` environment variable."
72
+ )
73
+
74
+ self._transport = AsyncHttpxClientWrapper(
75
+ base_url=base_url,
76
+ api_key=resolved_key,
77
+ timeout=timeout,
78
+ max_retries=max_retries,
79
+ custom_headers=default_headers,
80
+ )
81
+
82
+ # Resource namespaces
83
+ self.ringing = AsyncRingingResource(self)
84
+ self.assistants = AsyncAssistantsResource(self)
85
+
86
+ async def health(self) -> HealthResponse:
87
+ """Check API service health status asynchronously."""
88
+ data = await self._transport.request("GET", "/v1/health")
89
+ return HealthResponse.model_validate(data or {})
90
+
91
+ async def close(self) -> None:
92
+ """Close the active client session asynchronously."""
93
+ await self._transport.close()
94
+
95
+ async def __aenter__(self) -> "AsyncCallaider":
96
+ return self
97
+
98
+ async def __aexit__(self, *args: object) -> None:
99
+ await self.close()
@@ -0,0 +1,4 @@
1
+ DEFAULT_BASE_URL = "https://api.callaider.ai"
2
+ DEFAULT_TIMEOUT = 60.0
3
+ DEFAULT_MAX_RETRIES = 2
4
+ RAW_USER_AGENT = "callaider-python/0.1.0"
callaider/_models.py ADDED
@@ -0,0 +1,140 @@
1
+ from enum import Enum
2
+ from typing import Any
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+
6
+ class BaseModelStrict(BaseModel):
7
+ """Base Pydantic model with extra field tolerance and alias support."""
8
+ model_config = ConfigDict(extra="ignore", populate_by_name=True)
9
+
10
+
11
+ class HealthResponse(BaseModelStrict):
12
+ """API health status response."""
13
+ status: str = Field(default="ok")
14
+ timestamp: str | None = None
15
+
16
+
17
+ # --- Ringing Enums & Sub-models ---
18
+
19
+ class CampaignStatus(str, Enum):
20
+ DRAFT = "draft"
21
+ CREATED = "created"
22
+ IN_PROGRESS = "in_progress"
23
+ PAUSED = "paused"
24
+ COMPLETED = "completed"
25
+ FAILED = "failed"
26
+
27
+
28
+ class CampaignRecipient(BaseModelStrict):
29
+ """Single recipient details."""
30
+ phone: str
31
+ name: str | None = None
32
+ variables: dict[str, Any] = Field(default_factory=dict)
33
+
34
+
35
+ class Campaign(BaseModelStrict):
36
+ """Full campaign object representation."""
37
+ id: str | int
38
+ name: str | None = None
39
+ status: str | CampaignStatus
40
+ assistant_id: str | None = None
41
+ total_recipients: int | None = 0
42
+ created_at: str | None = None
43
+ updated_at: str | None = None
44
+
45
+
46
+ class CampaignStatistics(BaseModelStrict):
47
+ """Aggregated campaign performance statistics."""
48
+ campaign_id: str | int
49
+ total_calls: int = 0
50
+ completed_calls: int = 0
51
+ successful_calls: int = 0
52
+ failed_calls: int = 0
53
+ average_duration_seconds: float | None = None
54
+
55
+
56
+ class CallRecord(BaseModelStrict):
57
+ """Single call details and outcome."""
58
+ id: str | int
59
+ campaign_id: str | int | None = None
60
+ phone: str
61
+ status: str
62
+ duration_seconds: int | None = 0
63
+ recording_url: str | None = None
64
+ transcript: str | None = None
65
+ post_analysis: dict[str, Any] | None = None
66
+ created_at: str | None = None
67
+
68
+
69
+ class RecordingInfo(BaseModelStrict):
70
+ """Call audio recording metadata."""
71
+ call_id: str | int
72
+ recording_url: str
73
+ duration_seconds: int | None = None
74
+ format: str = "mp3"
75
+
76
+
77
+ # --- Assistants / External Conversation Models ---
78
+
79
+ class MessageItem(BaseModelStrict):
80
+ """Individual conversation message representation."""
81
+ role: str = "assistant"
82
+ content: str = ""
83
+ timestamp: str | None = None
84
+
85
+
86
+ class ExternalConversationMessageRequest(BaseModelStrict):
87
+ """Request payload for sending messages to an external assistant conversation."""
88
+ message: str
89
+ external_conversation_id: str = Field(alias="externalConversationId")
90
+ metadata: dict[str, Any] = Field(default_factory=dict)
91
+
92
+
93
+ class ExternalConversationMessageResponse(BaseModelStrict):
94
+ """Response returned by the AI assistant conversation bridge."""
95
+ ok: bool = True
96
+ status: str | None = None
97
+ external_conversation_id: str | None = Field(default=None, alias="externalConversationId")
98
+ reply: str | None = None
99
+ messages: list[MessageItem] = Field(default_factory=list)
100
+ state: dict[str, Any] = Field(default_factory=dict)
101
+
102
+ @property
103
+ def text(self) -> str:
104
+ """Convenience helper to extract the main assistant reply text."""
105
+ if self.reply:
106
+ return self.reply
107
+ if self.messages:
108
+ # Return content of the latest assistant message
109
+ for msg in reversed(self.messages):
110
+ if msg.role == "assistant" and msg.content:
111
+ return msg.content
112
+ return self.messages[-1].content
113
+ return ""
114
+
115
+ # --- Webhook Event Models ---
116
+
117
+ class WebhookEventType(str, Enum):
118
+ CALL_STARTED = "call.started"
119
+ CALL_ANSWERED = "call.answered"
120
+ CALL_COMPLETED = "call.completed"
121
+ CALL_FAILED = "call.failed"
122
+ CAMPAIGN_FINISHED = "campaign.finished"
123
+
124
+
125
+ class CallCompletedPayload(BaseModelStrict):
126
+ """Payload delivered when a call is finished."""
127
+ call_id: str | int
128
+ campaign_id: str | int | None = None
129
+ phone: str
130
+ duration_seconds: int = 0
131
+ recording_url: str | None = None
132
+ transcript: str | None = None
133
+ post_analysis: dict[str, Any] = Field(default_factory=dict)
134
+
135
+
136
+ class WebhookEvent(BaseModelStrict):
137
+ """Generic incoming Callaider webhook event wrapper."""
138
+ event: str | WebhookEventType
139
+ timestamp: str | None = None
140
+ data: CallCompletedPayload | dict[str, Any]
@@ -0,0 +1,117 @@
1
+ from typing import Generic, TypeVar, Optional, TYPE_CHECKING
2
+
3
+ import httpx2
4
+
5
+ if TYPE_CHECKING:
6
+ from ._client import OpenAI, AsyncOpenAI
7
+ from ._models import FinalRequestOptions
8
+
9
+ _T = TypeVar("_T")
10
+
11
+ class Stream(Generic[_T]):
12
+ """Provides the core interface to iterate over a synchronous stream response."""
13
+
14
+ response: httpx2.Response
15
+ _options: Optional[FinalRequestOptions] = None
16
+ _decoder: SSEBytesDecoder
17
+
18
+ def __init__(
19
+ self,
20
+ *,
21
+ cast_to: type[_T],
22
+ response: httpx2.Response,
23
+ client: OpenAI,
24
+ options: Optional[FinalRequestOptions] = None,
25
+ ) -> None:
26
+ self.response = response
27
+ self._cast_to = cast_to
28
+ self._client = client
29
+ self._options = options
30
+ self._decoder = client._make_sse_decoder()
31
+ self._iterator = self.__stream__()
32
+
33
+ def __next__(self) -> _T:
34
+ return self._iterator.__next__()
35
+
36
+ def __iter__(self) -> Iterator[_T]:
37
+ for item in self._iterator:
38
+ yield item
39
+
40
+ def _iter_events(self) -> Iterator[ServerSentEvent]:
41
+ yield from self._decoder.iter_bytes(self.response.iter_bytes())
42
+
43
+ def __stream__(self) -> Iterator[_T]:
44
+ cast_to = cast(Any, self._cast_to)
45
+ response = self.response
46
+ process_data = self._client._process_response_data
47
+ iterator = self._iter_events()
48
+
49
+ try:
50
+ for sse in iterator:
51
+ if sse.data.startswith("[DONE]"):
52
+ break
53
+
54
+ # we have to special case the Assistants `thread.` events since we won't have an "event" key in the data
55
+ if sse.event and sse.event.startswith("thread."):
56
+ data = sse.json()
57
+
58
+ if sse.event == "error" and is_mapping(data) and data.get("error"):
59
+ message = None
60
+ error = data.get("error")
61
+ if is_mapping(error):
62
+ message = error.get("message")
63
+ if not message or not isinstance(message, str):
64
+ message = "An error occurred during streaming"
65
+
66
+ raise APIError(
67
+ message=message,
68
+ request=self.response.request,
69
+ body=data["error"],
70
+ )
71
+
72
+ yield process_data(data={"data": data, "event": sse.event}, cast_to=cast_to, response=response)
73
+ else:
74
+ data = sse.json()
75
+ if is_mapping(data) and data.get("error"):
76
+ message = None
77
+ error = data.get("error")
78
+ if is_mapping(error):
79
+ message = error.get("message")
80
+ if not message or not isinstance(message, str):
81
+ message = "An error occurred during streaming"
82
+
83
+ raise APIError(
84
+ message=message,
85
+ request=self.response.request,
86
+ body=data["error"],
87
+ )
88
+
89
+ yield process_data(
90
+ data={"data": data, "event": sse.event}
91
+ if self._options is not None and self._options.synthesize_event_and_data
92
+ else data,
93
+ cast_to=cast_to,
94
+ response=response,
95
+ )
96
+ finally:
97
+ # Ensure the response is closed even if the consumer doesn't read all data
98
+ response.close()
99
+
100
+ def __enter__(self) -> Self:
101
+ return self
102
+
103
+ def __exit__(
104
+ self,
105
+ exc_type: type[BaseException] | None,
106
+ exc: BaseException | None,
107
+ exc_tb: TracebackType | None,
108
+ ) -> None:
109
+ self.close()
110
+
111
+ def close(self) -> None:
112
+ """
113
+ Close the response and release the connection.
114
+
115
+ Automatically called if the response body is read to completion.
116
+ """
117
+ self.response.close()
@@ -0,0 +1,55 @@
1
+ from typing import Any
2
+ import httpx
3
+
4
+
5
+ class CallaiderError(Exception):
6
+ """Base exception for all Callaider SDK errors."""
7
+ pass
8
+
9
+
10
+ class APIError(CallaiderError):
11
+ """Raised when the API returns an error response."""
12
+
13
+ def __init__(
14
+ self,
15
+ message: str,
16
+ *,
17
+ request: httpx.Request | None = None,
18
+ response: httpx.Response | None = None,
19
+ body: Any | None = None,
20
+ ) -> None:
21
+ super().__init__(message)
22
+ self.request = request
23
+ self.response = response
24
+ self.status_code = response.status_code if response else None
25
+ self.body = body
26
+
27
+
28
+ class APIStatusError(APIError):
29
+ """Raised for non-2xx HTTP status codes."""
30
+ pass
31
+
32
+
33
+ class AuthenticationError(APIStatusError):
34
+ """Raised for 401 Unauthorized and 403 Forbidden errors."""
35
+ pass
36
+
37
+
38
+ class NotFoundError(APIStatusError):
39
+ """Raised for 404 Not Found errors."""
40
+ pass
41
+
42
+
43
+ class RateLimitError(APIStatusError):
44
+ """Raised for 429 Too Many Requests errors."""
45
+ pass
46
+
47
+
48
+ class InternalServerError(APIStatusError):
49
+ """Raised for 5xx Server Errors."""
50
+ pass
51
+
52
+
53
+ class APIConnectionError(APIError):
54
+ """Raised when a network connection error occurs (DNS, timeout, connection drop)."""
55
+ pass
File without changes
@@ -0,0 +1,96 @@
1
+ from typing import Any, TYPE_CHECKING
2
+ from callaider._models import (
3
+ ExternalConversationMessageRequest,
4
+ ExternalConversationMessageResponse,
5
+ )
6
+
7
+ if TYPE_CHECKING:
8
+ from callaider._client import AsyncCallaider, Callaider
9
+
10
+
11
+ class AssistantsResource:
12
+ """Synchronous interface for conversational assistants and bridge integrations."""
13
+
14
+ def __init__(self, client: "Callaider") -> None:
15
+ self._client = client
16
+
17
+ def send_message(
18
+ self,
19
+ assistant_id: str | int,
20
+ *,
21
+ message: str,
22
+ external_conversation_id: str = "conv_default_123",
23
+ metadata: dict[str, Any] | None = None,
24
+ ) -> ExternalConversationMessageResponse:
25
+ """Send a message to an AI assistant and receive a contextual response.
26
+
27
+ :param assistant_id: Unique identifier of the assistant.
28
+ :param message: Text prompt/message from the user.
29
+ :param external_conversation_id: External session ID for conversation state.
30
+ :param metadata: Optional metadata dictionary.
31
+ """
32
+ request_model = ExternalConversationMessageRequest(
33
+ message=message,
34
+ externalConversationId=external_conversation_id,
35
+ metadata=metadata or {},
36
+ )
37
+
38
+ data = self._client._transport.request(
39
+ "POST",
40
+ f"/v1/assistants/{assistant_id}/external-conversations/messages",
41
+ json_data=request_model.model_dump(by_alias=True, exclude_none=True),
42
+ )
43
+ return ExternalConversationMessageResponse.model_validate(data)
44
+
45
+ def get_conversation_state(
46
+ self,
47
+ assistant_id: str | int,
48
+ external_conversation_id: str,
49
+ ) -> dict[str, Any]:
50
+ """Retrieve conversation state by external conversation ID."""
51
+ data = self._client._transport.request(
52
+ "GET",
53
+ f"/v1/assistants/{assistant_id}/external-conversations/{external_conversation_id}/state",
54
+ )
55
+ return data or {}
56
+
57
+
58
+ class AsyncAssistantsResource:
59
+ """Asynchronous interface for conversational assistants and bridge integrations."""
60
+
61
+ def __init__(self, client: "AsyncCallaider") -> None:
62
+ self._client = client
63
+
64
+ async def send_message(
65
+ self,
66
+ assistant_id: str | int,
67
+ *,
68
+ message: str,
69
+ external_conversation_id: str = "conv_default_123",
70
+ metadata: dict[str, Any] | None = None,
71
+ ) -> ExternalConversationMessageResponse:
72
+ """Send a message to an AI assistant asynchronously."""
73
+ request_model = ExternalConversationMessageRequest(
74
+ message=message,
75
+ externalConversationId=external_conversation_id,
76
+ metadata=metadata or {},
77
+ )
78
+
79
+ data = await self._client._transport.request(
80
+ "POST",
81
+ f"/v1/assistants/{assistant_id}/external-conversations/messages",
82
+ json_data=request_model.model_dump(by_alias=True, exclude_none=True),
83
+ )
84
+ return ExternalConversationMessageResponse.model_validate(data)
85
+
86
+ async def get_conversation_state(
87
+ self,
88
+ assistant_id: str | int,
89
+ external_conversation_id: str,
90
+ ) -> dict[str, Any]:
91
+ """Retrieve conversation state asynchronously."""
92
+ data = await self._client._transport.request(
93
+ "GET",
94
+ f"/v1/assistants/{assistant_id}/external-conversations/{external_conversation_id}/state",
95
+ )
96
+ return data or {}
@@ -0,0 +1,171 @@
1
+ from typing import Any, TYPE_CHECKING
2
+ from callaider._models import (
3
+ Campaign,
4
+ CampaignRecipient,
5
+ CampaignStatistics,
6
+ CallRecord,
7
+ RecordingInfo,
8
+ )
9
+
10
+ if TYPE_CHECKING:
11
+ from callaider._client import AsyncCallaider, Callaider
12
+
13
+
14
+ class RingingResource:
15
+ """Synchronous client interface for the Ringing (outbound calls) module."""
16
+
17
+ def __init__(self, client: "Callaider") -> None:
18
+ self._client = client
19
+
20
+ def list_campaigns(self) -> list[Campaign]:
21
+ """Fetch all outbound campaigns."""
22
+ data = self._client._transport.request("GET", "/v1/ringing/campaigns")
23
+ items = data if isinstance(data, list) else data.get("data", [])
24
+ return [Campaign.model_validate(c) for c in items]
25
+
26
+ def create_campaign(
27
+ self,
28
+ *,
29
+ assistant_id: str,
30
+ recipients: list[CampaignRecipient | dict[str, Any]],
31
+ name: str | None = None,
32
+ ) -> Campaign:
33
+ """Create a new outbound call campaign."""
34
+ parsed_recipients = [
35
+ r.model_dump() if isinstance(r, CampaignRecipient) else r
36
+ for r in recipients
37
+ ]
38
+ payload: dict[str, Any] = {
39
+ "assistant_id": assistant_id,
40
+ "recipients": parsed_recipients,
41
+ }
42
+ if name:
43
+ payload["name"] = name
44
+
45
+ data = self._client._transport.request("POST", "/v1/ringing/campaigns", json_data=payload)
46
+ return Campaign.model_validate(data)
47
+
48
+ def get_campaign(self, campaign_id: str | int) -> Campaign:
49
+ """Retrieve details of a specific campaign."""
50
+ data = self._client._transport.request("GET", f"/v1/ringing/campaigns/{campaign_id}")
51
+ return Campaign.model_validate(data)
52
+
53
+ def delete_campaign(self, campaign_id: str | int) -> None:
54
+ """Delete a campaign by ID."""
55
+ self._client._transport.request("DELETE", f"/v1/ringing/campaigns/{campaign_id}")
56
+
57
+ def launch_campaign(self, campaign_id: str | int) -> Campaign:
58
+ """Start or schedule execution of a campaign."""
59
+ data = self._client._transport.request("POST", f"/v1/ringing/campaigns/{campaign_id}/launch")
60
+ return Campaign.model_validate(data)
61
+
62
+ def pause_campaign(self, campaign_id: str | int) -> Campaign:
63
+ """Pause an active campaign."""
64
+ data = self._client._transport.request("POST", f"/v1/ringing/campaigns/{campaign_id}/pause")
65
+ return Campaign.model_validate(data)
66
+
67
+ def resume_campaign(self, campaign_id: str | int) -> Campaign:
68
+ """Resume a paused campaign."""
69
+ data = self._client._transport.request("POST", f"/v1/ringing/campaigns/{campaign_id}/resume")
70
+ return Campaign.model_validate(data)
71
+
72
+ def get_campaign_statistics(self, campaign_id: str | int) -> CampaignStatistics:
73
+ """Retrieve aggregated statistics for a specific campaign."""
74
+ data = self._client._transport.request("GET", f"/v1/ringing/campaigns/{campaign_id}/statistics")
75
+ return CampaignStatistics.model_validate(data)
76
+
77
+ def list_campaign_calls(self, campaign_id: str | int) -> list[CallRecord]:
78
+ """Fetch all call records belonging to a campaign."""
79
+ data = self._client._transport.request("GET", f"/v1/ringing/campaigns/{campaign_id}/calls")
80
+ items = data if isinstance(data, list) else data.get("data", [])
81
+ return [CallRecord.model_validate(item) for item in items]
82
+
83
+ def get_call(self, call_id: str | int) -> CallRecord:
84
+ """Retrieve details of an individual call record."""
85
+ data = self._client._transport.request("GET", f"/v1/ringing/calls/{call_id}")
86
+ return CallRecord.model_validate(data)
87
+
88
+ def get_call_recording(self, call_id: str | int) -> RecordingInfo:
89
+ """Retrieve the audio recording link and metadata for a specific call."""
90
+ data = self._client._transport.request("GET", f"/v1/ringing/calls/{call_id}/recording")
91
+ return RecordingInfo.model_validate(data)
92
+
93
+
94
+ class AsyncRingingResource:
95
+ """Asynchronous client interface for the Ringing (outbound calls) module."""
96
+
97
+ def __init__(self, client: "AsyncCallaider") -> None:
98
+ self._client = client
99
+
100
+ async def list_campaigns(self) -> list[Campaign]:
101
+ """Fetch all outbound campaigns asynchronously."""
102
+ data = await self._client._transport.request("GET", "/v1/ringing/campaigns")
103
+ items = data if isinstance(data, list) else data.get("data", [])
104
+ return [Campaign.model_validate(c) for c in items]
105
+
106
+ async def create_campaign(
107
+ self,
108
+ *,
109
+ assistant_id: str,
110
+ recipients: list[CampaignRecipient | dict[str, Any]],
111
+ name: str | None = None,
112
+ ) -> Campaign:
113
+ """Create a new outbound call campaign asynchronously."""
114
+ parsed_recipients = [
115
+ r.model_dump() if isinstance(r, CampaignRecipient) else r
116
+ for r in recipients
117
+ ]
118
+ payload: dict[str, Any] = {
119
+ "assistant_id": assistant_id,
120
+ "recipients": parsed_recipients,
121
+ }
122
+ if name:
123
+ payload["name"] = name
124
+
125
+ data = await self._client._transport.request("POST", "/v1/ringing/campaigns", json_data=payload)
126
+ return Campaign.model_validate(data)
127
+
128
+ async def get_campaign(self, campaign_id: str | int) -> Campaign:
129
+ """Retrieve details of a specific campaign asynchronously."""
130
+ data = await self._client._transport.request("GET", f"/v1/ringing/campaigns/{campaign_id}")
131
+ return Campaign.model_validate(data)
132
+
133
+ async def delete_campaign(self, campaign_id: str | int) -> None:
134
+ """Delete a campaign by ID asynchronously."""
135
+ await self._client._transport.request("DELETE", f"/v1/ringing/campaigns/{campaign_id}")
136
+
137
+ async def launch_campaign(self, campaign_id: str | int) -> Campaign:
138
+ """Start or schedule execution of a campaign asynchronously."""
139
+ data = await self._client._transport.request("POST", f"/v1/ringing/campaigns/{campaign_id}/launch")
140
+ return Campaign.model_validate(data)
141
+
142
+ async def pause_campaign(self, campaign_id: str | int) -> Campaign:
143
+ """Pause an active campaign asynchronously."""
144
+ data = await self._client._transport.request("POST", f"/v1/ringing/campaigns/{campaign_id}/pause")
145
+ return Campaign.model_validate(data)
146
+
147
+ async def resume_campaign(self, campaign_id: str | int) -> Campaign:
148
+ """Resume a paused campaign asynchronously."""
149
+ data = await self._client._transport.request("POST", f"/v1/ringing/campaigns/{campaign_id}/resume")
150
+ return Campaign.model_validate(data)
151
+
152
+ async def get_campaign_statistics(self, campaign_id: str | int) -> CampaignStatistics:
153
+ """Retrieve aggregated statistics for a specific campaign asynchronously."""
154
+ data = await self._client._transport.request("GET", f"/v1/ringing/campaigns/{campaign_id}/statistics")
155
+ return CampaignStatistics.model_validate(data)
156
+
157
+ async def list_campaign_calls(self, campaign_id: str | int) -> list[CallRecord]:
158
+ """Fetch all call records belonging to a campaign asynchronously."""
159
+ data = await self._client._transport.request("GET", f"/v1/ringing/campaigns/{campaign_id}/calls")
160
+ items = data if isinstance(data, list) else data.get("data", [])
161
+ return [CallRecord.model_validate(item) for item in items]
162
+
163
+ async def get_call(self, call_id: str | int) -> CallRecord:
164
+ """Retrieve details of an individual call record asynchronously."""
165
+ data = await self._client._transport.request("GET", f"/v1/ringing/calls/{call_id}")
166
+ return CallRecord.model_validate(data)
167
+
168
+ async def get_call_recording(self, call_id: str | int) -> RecordingInfo:
169
+ """Retrieve the audio recording link and metadata for a specific call asynchronously."""
170
+ data = await self._client._transport.request("GET", f"/v1/ringing/calls/{call_id}/recording")
171
+ return RecordingInfo.model_validate(data)
callaider/webhooks.py ADDED
@@ -0,0 +1,40 @@
1
+ import json
2
+ from typing import Any, Union
3
+ from callaider._models import WebhookEvent
4
+ from callaider.exceptions import CallaiderError
5
+
6
+
7
+ class WebhookParsingError(CallaiderError):
8
+ """Raised when an incoming webhook payload cannot be parsed."""
9
+ pass
10
+
11
+
12
+ class Webhooks:
13
+ """Helper utilities for parsing and verifying Callaider webhooks."""
14
+
15
+ @staticmethod
16
+ def construct_event(
17
+ payload: Union[str, bytes, dict[str, Any]],
18
+ *,
19
+ secret: str | None = None, # Reserved for signature verification if HMAC is used
20
+ ) -> WebhookEvent:
21
+ """Parse raw incoming HTTP payload into a strongly-typed WebhookEvent.
22
+
23
+ :param payload: Raw request body (bytes, str or parsed dict).
24
+ :param secret: Optional webhook signing secret.
25
+ :raises WebhookParsingError: If payload is invalid JSON or does not match schema.
26
+ :return: Validated WebhookEvent object.
27
+ """
28
+ try:
29
+ if isinstance(payload, (bytes, bytearray)):
30
+ data = json.loads(payload.decode("utf-8"))
31
+ elif isinstance(payload, str):
32
+ data = json.loads(payload)
33
+ elif isinstance(payload, dict):
34
+ data = payload
35
+ else:
36
+ raise WebhookParsingError(f"Unsupported payload type: {type(payload)}")
37
+
38
+ return WebhookEvent.model_validate(data)
39
+ except Exception as exc:
40
+ raise WebhookParsingError(f"Failed to parse webhook event: {exc}") from exc
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.5
2
+ Name: callaider
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Callaider AI Voice Platform
5
+ Project-URL: Homepage, https://github.com/astatdeglebantiy/callaider-sdk-python
6
+ Author-email: AstatdeGlebantiy <glebsh2chko@gmail.com>
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: httpx2>=2.12.0
11
+ Requires-Dist: pydantic>=2.0.0
12
+ Requires-Dist: pytest-asyncio>=0.23.0
13
+ Requires-Dist: pytest>=8.0.0
14
+ Requires-Dist: respx>=0.21.0
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Callaider Python SDK
18
+
19
+ [![PyPI version](https://img.shields.io/pypi/v/callaider.svg)](https://pypi.org/project/callaider/)
20
+ [![Python versions](https://img.shields.io/pypi/pyversions/callaider.svg)](https://pypi.org/project/callaider/)
21
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
22
+
23
+ The official Python client library for the [Callaider](https://callaider.ai) AI voice calling and conversational platform.
24
+
25
+ Built on top of the [Callaider OpenAPI 3.0.3 specification](https://github.com/NBM-Labs/callaider_openapi).
26
+
27
+ ---
28
+
29
+ ## Features
30
+
31
+ - ⚡ **Sync & Async clients** built on modern `httpx`.
32
+ - 🛡️ **Fully typed** request/response models with Pydantic v2.
33
+ - 🔄 **Automatic retries** with exponential backoff on 429/5xx errors.
34
+ - 📞 **Complete Ringing API support**: Campaigns, batch calls, recordings, and statistics.
35
+ - 🤖 **External Conversation Bridge**: Direct AI assistant messaging integration (ideal for Telegram bots).
36
+ - 🪝 **Built-in Webhooks parser**: Typed events parsing for callback handling.
37
+
38
+ ---
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ pip install callaider
44
+ ```
45
+
46
+ Or using `uv`:
47
+
48
+ ```bash
49
+ uv add callaider
50
+ ```
51
+
52
+ ---
53
+
54
+ ## Quickstart
55
+
56
+ ### Synchronous Usage
57
+
58
+ ```python
59
+ from callaider import Callaider
60
+
61
+ client = Callaider(api_key="your_api_key_here")
62
+
63
+ # 1. Check API status
64
+ health = client.health()
65
+ print(f"API status: {health.status}")
66
+
67
+ # 2. Create an automated call campaign
68
+ campaign = client.ringing.create_campaign(
69
+ assistant_id="asst_sales_01",
70
+ recipients=[
71
+ {"phone": "+380501234567", "name": "Alex"}
72
+ ],
73
+ name="VIP Outreach Campaign"
74
+ )
75
+ print(f"Created campaign #{campaign.id} with status: {campaign.status}")
76
+
77
+ # 3. Launch the campaign
78
+ client.ringing.launch_campaign(campaign.id)
79
+ ```
80
+
81
+ ### Asynchronous Usage (FastAPI, Asyncio, Telegram Bots)
82
+
83
+ ```python
84
+ import asyncio
85
+ from callaider import AsyncCallaider
86
+
87
+ async def main():
88
+ async with AsyncCallaider(api_key="your_api_key_here") as client:
89
+ # Send a message to AI assistant bridge
90
+ response = await client.assistants.send_message(
91
+ assistant_id="asst_support_01",
92
+ external_conversation_id="telegram_user_12345",
93
+ message="Hello! I need help with my order."
94
+ )
95
+ print("AI Response:", response.text)
96
+
97
+ asyncio.run(main())
98
+ ```
99
+
100
+ ---
101
+
102
+ ## Webhooks Handling
103
+
104
+ Easily parse incoming Callaider webhook payloads in FastAPI or Flask:
105
+
106
+ ```python
107
+ from fastapi import FastAPI, Request
108
+ from callaider import Webhooks
109
+
110
+ app = FastAPI()
111
+
112
+ @app.post("/webhooks/callaider")
113
+ async def handle_webhook(request: Request):
114
+ payload = await request.body()
115
+ event = Webhooks.construct_event(payload)
116
+
117
+ if event.event == "call.completed":
118
+ print(f"Call {event.data.call_id} finished. Duration: {event.data.duration_seconds}s")
119
+ print(f"Audio recording: {event.data.recording_url}")
120
+
121
+ return {"status": "ok"}
122
+ ```
123
+
124
+ ---
125
+
126
+ ## Development & Testing
127
+
128
+ ```bash
129
+ # Clone the repository
130
+ git clone https://github.com/astatdeglebantiy/callaider-python.git
131
+ cd callaider-python
132
+
133
+ # Install dependencies with uv
134
+ uv sync --all-extras
135
+
136
+ # Run unit tests
137
+ uv run pytest
138
+ ```
139
+
140
+ ---
141
+
142
+ ## License
143
+
144
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
@@ -0,0 +1,15 @@
1
+ callaider/__init__.py,sha256=4CUuEgT4EC6tfI8WAmZPZj7MwvU3r2VPEplv_lPebZ0,600
2
+ callaider/_base_client.py,sha256=HN6ZHkWIJKi4ynnzKGh_G8PkvmCBVAjfdoKCHJnFBM8,7147
3
+ callaider/_client.py,sha256=3nhI8-VLNcnuojZ2pG4bDKWTmtIzcODp7bVM6WPpvYU,3344
4
+ callaider/_constants.py,sha256=ThJNLsxDcoCsqRuS_PclGE0w6euRtqrKMY5u5LA7SOw,134
5
+ callaider/_models.py,sha256=5OeBqB0UukCkJicVh601W00m8-Z2MVoDKurln96wGkQ,4198
6
+ callaider/_streaming.py,sha256=Fg5urQlLBlqPQCmcR1OEMPGE_uaCLToQW5qEqso6Gi4,4099
7
+ callaider/exceptions.py,sha256=4eKAHoo5OuIJd1ybIsss7irxpCs6wSidWaz83dnnOXw,1256
8
+ callaider/webhooks.py,sha256=nzHJNYcS7JOFUgmyrbG-tVG7Is8wH4-LYUHJsYtREuU,1492
9
+ callaider/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ callaider/resources/assistants.py,sha256=SHGZXWhntMbMT6VEJSkSTU84_ay4wFSVl-Fpjx6GlB4,3443
11
+ callaider/resources/ringing.py,sha256=nOvRiw0UYlDkCVNOyYj3lnj3B7GV0vpG5_zGomi97yY,7937
12
+ callaider-0.1.0.dist-info/METADATA,sha256=ejzmdIoa-y12s2eab2qpwDP-I6tYq5JXX6_QvmSoLsQ,3761
13
+ callaider-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
14
+ callaider-0.1.0.dist-info/licenses/LICENSE,sha256=KDKJENJgzihiZEqAmD8Bp59OY9Cyn0fA6toKkninS7g,1061
15
+ callaider-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gleb
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.