ahs-cti-api-sdk 0.1.0a1__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,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: ahs-cti-api-sdk
3
+ Version: 0.1.0a1
4
+ Summary: Python SDK for the AHS CTI Platform
5
+ License: Proprietary
6
+ Keywords: cti,telephony,contact-center,ahs
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: httpx>=0.25.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=7.0; extra == "dev"
12
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
13
+ Requires-Dist: respx>=0.20; extra == "dev"
14
+
15
+ # AHS CTI Python SDK
16
+
17
+ Python SDK for the AHS CTI Platform — initiate and control outbound calls via REST API.
18
+
19
+ **Requirements:** Python ≥ 3.9 · `httpx ≥ 0.25`
20
+
21
+ ---
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ pip install ahs-cti-api-sdk
27
+ ```
28
+
29
+ ---
30
+
31
+ ## Quick Start
32
+
33
+ ### API-Key Authentication (recommended for server integrations)
34
+
35
+ ```python
36
+ from ahs_cti import CtiClient
37
+
38
+ with CtiClient(base_url="https://cti.example.com", api_key="ctsk_...", tenant_id=1) as client:
39
+ result = client.calls.dial("+919876543210")
40
+ print(result.call_id) # save this for end_call
41
+
42
+ end = client.calls.end_call(
43
+ result.call_id,
44
+ disposition="INTERESTED",
45
+ list_comments="Customer wants a demo next week",
46
+ )
47
+ print(end.duration)
48
+ ```
49
+
50
+ ### Credential Authentication
51
+
52
+ ```python
53
+ from ahs_cti import CtiClient
54
+
55
+ with CtiClient(base_url="https://cti.example.com", user_id="agent1", password="secret") as client:
56
+ result = client.calls.dial("+919876543210", caller_id="AH2007C")
57
+ ```
58
+
59
+ ### Async Client
60
+
61
+ ```python
62
+ import asyncio
63
+ from ahs_cti import AsyncCtiClient
64
+
65
+ async def main():
66
+ async with AsyncCtiClient(base_url="https://cti.example.com", api_key="ctsk_...") as client:
67
+ result = await client.calls.dial("+919876543210")
68
+ print(result.call_id)
69
+
70
+ asyncio.run(main())
71
+ ```
72
+
73
+ ---
74
+
75
+ ## ClientConfig Reference
76
+
77
+ For full control over transport settings, use `ClientConfig`:
78
+
79
+ ```python
80
+ from ahs_cti import CtiClient, ClientConfig
81
+
82
+ config = ClientConfig(
83
+ base_url="https://cti.example.com",
84
+ timeout=15.0,
85
+ verify_ssl=False, # for self-signed certificates
86
+ max_retries=5,
87
+ )
88
+
89
+ with CtiClient(config=config, api_key="ctsk_...") as client:
90
+ result = client.calls.dial("+919876543210")
91
+ ```
92
+
93
+ | Parameter | Type | Default | Description |
94
+ |---|---|---|---|
95
+ | `base_url` | `str` | required | Root URL of the CTI platform |
96
+ | `timeout` | `float` | `30.0` | Request timeout in seconds |
97
+ | `verify_ssl` | `bool` | `True` | Set `False` for self-signed certs |
98
+ | `max_retries` | `int` | `3` | Retry attempts on 5xx / network errors |
99
+
100
+ ---
101
+
102
+ ## Error Handling
103
+
104
+ ```python
105
+ from ahs_cti import CtiClient, CtiAuthError, CtiApiError, CtiRetryExhaustedError
106
+
107
+ with CtiClient(...) as client:
108
+ try:
109
+ result = client.calls.dial("+919876543210")
110
+ except CtiAuthError:
111
+ # Invalid or expired API key / credentials
112
+ print("Authentication failed")
113
+ except CtiRetryExhaustedError as e:
114
+ # All retry attempts failed (5xx or network)
115
+ print(f"Failed after {e.attempts} attempts: {e.last_exc}")
116
+ except CtiApiError as e:
117
+ # Any other API error (400, 404, 422 …)
118
+ print(f"API error [{e.status_code}]: {e}")
119
+ ```
120
+
121
+ ### Exception Hierarchy
122
+
123
+ ```
124
+ CtiError
125
+ ├── CtiAuthError — 401 / login failure
126
+ └── CtiApiError — 4xx / 5xx from the platform
127
+ └── CtiRetryExhaustedError — all retries exhausted
128
+ ```
129
+
130
+ ---
131
+
132
+ ## Retry Behaviour
133
+
134
+ The SDK automatically retries on `500`, `502`, `503`, `504` responses and transient
135
+ network errors (`ConnectError`, `TimeoutException`). Retries use exponential backoff:
136
+
137
+ | Attempt | Delay |
138
+ |---|---|
139
+ | 1st (initial) | none |
140
+ | 2nd retry | 2 s |
141
+ | 3rd retry | 4 s |
142
+ | 4th retry | 8 s |
143
+
144
+ Set `max_retries=0` in `ClientConfig` to disable retries entirely.
145
+
146
+ ---
147
+
148
+ ## Self-Signed Certificates
149
+
150
+ Internal / staging servers often use self-signed TLS certificates. Pass `verify_ssl=False`:
151
+
152
+ ```python
153
+ client = CtiClient(base_url="https://192.168.1.10:8082", api_key="ctsk_...", verify_ssl=False)
154
+ ```
155
+
156
+ > Do **not** disable SSL verification in production.
@@ -0,0 +1,142 @@
1
+ # AHS CTI Python SDK
2
+
3
+ Python SDK for the AHS CTI Platform — initiate and control outbound calls via REST API.
4
+
5
+ **Requirements:** Python ≥ 3.9 · `httpx ≥ 0.25`
6
+
7
+ ---
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install ahs-cti-api-sdk
13
+ ```
14
+
15
+ ---
16
+
17
+ ## Quick Start
18
+
19
+ ### API-Key Authentication (recommended for server integrations)
20
+
21
+ ```python
22
+ from ahs_cti import CtiClient
23
+
24
+ with CtiClient(base_url="https://cti.example.com", api_key="ctsk_...", tenant_id=1) as client:
25
+ result = client.calls.dial("+919876543210")
26
+ print(result.call_id) # save this for end_call
27
+
28
+ end = client.calls.end_call(
29
+ result.call_id,
30
+ disposition="INTERESTED",
31
+ list_comments="Customer wants a demo next week",
32
+ )
33
+ print(end.duration)
34
+ ```
35
+
36
+ ### Credential Authentication
37
+
38
+ ```python
39
+ from ahs_cti import CtiClient
40
+
41
+ with CtiClient(base_url="https://cti.example.com", user_id="agent1", password="secret") as client:
42
+ result = client.calls.dial("+919876543210", caller_id="AH2007C")
43
+ ```
44
+
45
+ ### Async Client
46
+
47
+ ```python
48
+ import asyncio
49
+ from ahs_cti import AsyncCtiClient
50
+
51
+ async def main():
52
+ async with AsyncCtiClient(base_url="https://cti.example.com", api_key="ctsk_...") as client:
53
+ result = await client.calls.dial("+919876543210")
54
+ print(result.call_id)
55
+
56
+ asyncio.run(main())
57
+ ```
58
+
59
+ ---
60
+
61
+ ## ClientConfig Reference
62
+
63
+ For full control over transport settings, use `ClientConfig`:
64
+
65
+ ```python
66
+ from ahs_cti import CtiClient, ClientConfig
67
+
68
+ config = ClientConfig(
69
+ base_url="https://cti.example.com",
70
+ timeout=15.0,
71
+ verify_ssl=False, # for self-signed certificates
72
+ max_retries=5,
73
+ )
74
+
75
+ with CtiClient(config=config, api_key="ctsk_...") as client:
76
+ result = client.calls.dial("+919876543210")
77
+ ```
78
+
79
+ | Parameter | Type | Default | Description |
80
+ |---|---|---|---|
81
+ | `base_url` | `str` | required | Root URL of the CTI platform |
82
+ | `timeout` | `float` | `30.0` | Request timeout in seconds |
83
+ | `verify_ssl` | `bool` | `True` | Set `False` for self-signed certs |
84
+ | `max_retries` | `int` | `3` | Retry attempts on 5xx / network errors |
85
+
86
+ ---
87
+
88
+ ## Error Handling
89
+
90
+ ```python
91
+ from ahs_cti import CtiClient, CtiAuthError, CtiApiError, CtiRetryExhaustedError
92
+
93
+ with CtiClient(...) as client:
94
+ try:
95
+ result = client.calls.dial("+919876543210")
96
+ except CtiAuthError:
97
+ # Invalid or expired API key / credentials
98
+ print("Authentication failed")
99
+ except CtiRetryExhaustedError as e:
100
+ # All retry attempts failed (5xx or network)
101
+ print(f"Failed after {e.attempts} attempts: {e.last_exc}")
102
+ except CtiApiError as e:
103
+ # Any other API error (400, 404, 422 …)
104
+ print(f"API error [{e.status_code}]: {e}")
105
+ ```
106
+
107
+ ### Exception Hierarchy
108
+
109
+ ```
110
+ CtiError
111
+ ├── CtiAuthError — 401 / login failure
112
+ └── CtiApiError — 4xx / 5xx from the platform
113
+ └── CtiRetryExhaustedError — all retries exhausted
114
+ ```
115
+
116
+ ---
117
+
118
+ ## Retry Behaviour
119
+
120
+ The SDK automatically retries on `500`, `502`, `503`, `504` responses and transient
121
+ network errors (`ConnectError`, `TimeoutException`). Retries use exponential backoff:
122
+
123
+ | Attempt | Delay |
124
+ |---|---|
125
+ | 1st (initial) | none |
126
+ | 2nd retry | 2 s |
127
+ | 3rd retry | 4 s |
128
+ | 4th retry | 8 s |
129
+
130
+ Set `max_retries=0` in `ClientConfig` to disable retries entirely.
131
+
132
+ ---
133
+
134
+ ## Self-Signed Certificates
135
+
136
+ Internal / staging servers often use self-signed TLS certificates. Pass `verify_ssl=False`:
137
+
138
+ ```python
139
+ client = CtiClient(base_url="https://192.168.1.10:8082", api_key="ctsk_...", verify_ssl=False)
140
+ ```
141
+
142
+ > Do **not** disable SSL verification in production.
@@ -0,0 +1,24 @@
1
+ """AHS CTI Platform — Python SDK."""
2
+
3
+ from .client import AsyncCtiClient, CtiClient
4
+ from ._http import ClientConfig
5
+ from .exceptions import CtiApiError, CtiAuthError, CtiError, CtiRetryExhaustedError
6
+ from .models import DialResult, EndCallResult
7
+
8
+ __version__ = "0.5.0"
9
+
10
+ __all__ = [
11
+ # Clients
12
+ "CtiClient",
13
+ "AsyncCtiClient",
14
+ # Configuration
15
+ "ClientConfig",
16
+ # Models
17
+ "DialResult",
18
+ "EndCallResult",
19
+ # Exceptions
20
+ "CtiError",
21
+ "CtiAuthError",
22
+ "CtiApiError",
23
+ "CtiRetryExhaustedError",
24
+ ]
@@ -0,0 +1,236 @@
1
+ """Authentication strategies — sync and async, with shared-transport injection.
2
+
3
+ Design
4
+ ------
5
+ Auth strategies satisfy the ``SyncAuthStrategy`` / ``AsyncAuthStrategy``
6
+ protocols — they expose only ``get_headers()``. The HTTP transport is
7
+ injected so that the same ``httpx.Client`` (and its ``verify`` / ``timeout``
8
+ settings) is used for both login calls and regular API calls.
9
+
10
+ Adding a new auth method (e.g. OAuth2) requires only implementing the
11
+ protocol — no changes to ``_http.py`` or ``client.py``.
12
+ """
13
+
14
+ import logging
15
+ import time
16
+ from datetime import datetime, timezone
17
+ from typing import Dict, Optional
18
+
19
+ import httpx
20
+
21
+ from .exceptions import CtiAuthError
22
+
23
+ logger = logging.getLogger("ahs_cti.auth")
24
+
25
+ _MIME_JSON = "application/json"
26
+ _EXPIRY_BUFFER_SECS = 60
27
+ _DEFAULT_TOKEN_TTL = 55 * 60
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Protocols (open for extension, no import of concrete classes needed)
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ class SyncAuthStrategy:
36
+ """Interface for synchronous authentication strategies.
37
+
38
+ Any class that implements ``get_headers() -> Dict[str, str]``
39
+ satisfies this interface and can be used with ``_HttpClient``.
40
+ """
41
+
42
+ def get_headers(self) -> Dict[str, str]:
43
+ raise NotImplementedError
44
+
45
+
46
+ class AsyncAuthStrategy:
47
+ """Interface for asynchronous authentication strategies.
48
+
49
+ Any class that implements ``async get_headers() -> Dict[str, str]``
50
+ satisfies this interface and can be used with ``_AsyncHttpClient``.
51
+ """
52
+
53
+ async def get_headers(self) -> Dict[str, str]:
54
+ raise NotImplementedError
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Helpers
59
+ # ---------------------------------------------------------------------------
60
+
61
+
62
+ def _build_headers(auth_headers: Dict[str, str]) -> Dict[str, str]:
63
+ return {**auth_headers, "Content-Type": _MIME_JSON, "Accept": _MIME_JSON}
64
+
65
+
66
+ def _parse_expiry(expiry_str: Optional[str]) -> float:
67
+ if expiry_str:
68
+ try:
69
+ dt = datetime.fromisoformat(expiry_str.replace("Z", "+00:00"))
70
+ return dt.astimezone(timezone.utc).timestamp()
71
+ except ValueError:
72
+ pass
73
+ return time.time() + _DEFAULT_TOKEN_TTL
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Sync strategies
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ class _ApiKeyAuth(SyncAuthStrategy):
82
+ """Static API-key auth. Headers are built in memory — no network calls."""
83
+
84
+ def __init__(self, api_key: str, tenant_id: Optional[int] = None) -> None:
85
+ self._api_key = api_key
86
+ self._tenant_id = tenant_id
87
+
88
+ def get_headers(self) -> Dict[str, str]:
89
+ auth: Dict[str, str] = {"X-API-Key": self._api_key}
90
+ if self._tenant_id is not None:
91
+ auth["X-Tenant-ID"] = str(self._tenant_id)
92
+ return _build_headers(auth)
93
+
94
+
95
+ class _CredentialAuth(SyncAuthStrategy):
96
+ """Credential-based auth — lazy login, auto-refresh 60 s before expiry.
97
+
98
+ The caller injects the shared ``httpx.Client`` so that SSL and timeout
99
+ settings are consistent between the login call and regular API calls.
100
+
101
+ Args:
102
+ login_url: Full URL of the login endpoint.
103
+ user_id: Agent username.
104
+ password: Agent password.
105
+ http_client: Shared ``httpx.Client`` instance (borrowed, not owned).
106
+ """
107
+
108
+ def __init__(
109
+ self,
110
+ login_url: str,
111
+ user_id: str,
112
+ password: str,
113
+ http_client: httpx.Client,
114
+ ) -> None:
115
+ self._login_url = login_url
116
+ self._user_id = user_id
117
+ self._password = password
118
+ self._http_client = http_client
119
+ self._access_token: Optional[str] = None
120
+ self._token_expiry: float = 0.0
121
+
122
+ def get_headers(self) -> Dict[str, str]:
123
+ if not self._is_token_valid():
124
+ self._login()
125
+ return _build_headers({"Authorization": f"Bearer {self._access_token}"})
126
+
127
+ def _is_token_valid(self) -> bool:
128
+ return (
129
+ self._access_token is not None
130
+ and time.time() < self._token_expiry - _EXPIRY_BUFFER_SECS
131
+ )
132
+
133
+ def _login(self) -> None:
134
+ logger.debug("ahs_cti: acquiring token for user_id=%s", self._user_id)
135
+ try:
136
+ resp = self._http_client.post(
137
+ self._login_url,
138
+ json={"userId": self._user_id, "password": self._password},
139
+ )
140
+ except httpx.RequestError as exc:
141
+ raise CtiAuthError(f"Network error during login: {exc}") from exc
142
+ if resp.status_code != 200:
143
+ raise CtiAuthError(
144
+ f"Login failed (HTTP {resp.status_code}). "
145
+ "Verify user_id, password, and base_url."
146
+ )
147
+ data = resp.json()
148
+ token = data.get("accessToken")
149
+ if not token:
150
+ raise CtiAuthError("Login response did not contain an accessToken.")
151
+ self._access_token = token
152
+ self._token_expiry = _parse_expiry(data.get("expiration"))
153
+ logger.debug("ahs_cti: token acquired, expires at %s", self._token_expiry)
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Async strategies
158
+ # ---------------------------------------------------------------------------
159
+
160
+
161
+ class _AsyncApiKeyAuth(SyncAuthStrategy):
162
+ """API-key auth for use with the async client.
163
+
164
+ Satisfies ``SyncAuthStrategy`` — header building is pure in-memory,
165
+ so no coroutine is needed. ``_AsyncHttpClient`` resolves headers via
166
+ ``_resolve_headers()`` which handles both sync and async strategies.
167
+ """
168
+
169
+ def __init__(self, api_key: str, tenant_id: Optional[int] = None) -> None:
170
+ self._inner = _ApiKeyAuth(api_key=api_key, tenant_id=tenant_id)
171
+
172
+ def get_headers(self) -> Dict[str, str]:
173
+ return self._inner.get_headers()
174
+
175
+
176
+ class _AsyncCredentialAuth(AsyncAuthStrategy):
177
+ """Async credential auth — lazy login with asyncio.Lock to prevent races.
178
+
179
+ Args:
180
+ login_url: Full URL of the login endpoint.
181
+ user_id: Agent username.
182
+ password: Agent password.
183
+ http_client: Shared ``httpx.AsyncClient`` instance (borrowed, not owned).
184
+ """
185
+
186
+ def __init__(
187
+ self,
188
+ login_url: str,
189
+ user_id: str,
190
+ password: str,
191
+ http_client: httpx.AsyncClient,
192
+ ) -> None:
193
+ self._login_url = login_url
194
+ self._user_id = user_id
195
+ self._password = password
196
+ self._http_client = http_client
197
+ self._access_token: Optional[str] = None
198
+ self._token_expiry: float = 0.0
199
+ self._lock: Optional["asyncio.Lock"] = None # created lazily inside event loop
200
+
201
+ async def get_headers(self) -> Dict[str, str]:
202
+ import asyncio
203
+ if self._lock is None:
204
+ self._lock = asyncio.Lock()
205
+ async with self._lock:
206
+ if not self._is_token_valid():
207
+ await self._login()
208
+ return _build_headers({"Authorization": f"Bearer {self._access_token}"})
209
+
210
+ def _is_token_valid(self) -> bool:
211
+ return (
212
+ self._access_token is not None
213
+ and time.time() < self._token_expiry - _EXPIRY_BUFFER_SECS
214
+ )
215
+
216
+ async def _login(self) -> None:
217
+ logger.debug("ahs_cti: async acquiring token for user_id=%s", self._user_id)
218
+ try:
219
+ resp = await self._http_client.post(
220
+ self._login_url,
221
+ json={"userId": self._user_id, "password": self._password},
222
+ )
223
+ except httpx.RequestError as exc:
224
+ raise CtiAuthError(f"Network error during login: {exc}") from exc
225
+ if resp.status_code != 200:
226
+ raise CtiAuthError(
227
+ f"Login failed (HTTP {resp.status_code}). "
228
+ "Verify user_id, password, and base_url."
229
+ )
230
+ data = resp.json()
231
+ token = data.get("accessToken")
232
+ if not token:
233
+ raise CtiAuthError("Login response did not contain an accessToken.")
234
+ self._access_token = token
235
+ self._token_expiry = _parse_expiry(data.get("expiration"))
236
+ logger.debug("ahs_cti: async token acquired, expires at %s", self._token_expiry)
@@ -0,0 +1,56 @@
1
+ """Central API endpoint registry.
2
+
3
+ All paths are versioned under API_VERSION. To upgrade the entire SDK
4
+ to a new API version, change API_VERSION here — no other file needs editing.
5
+
6
+ Usage::
7
+
8
+ from ._endpoints import Endpoints
9
+ self._http.post(Endpoints.DIAL, body)
10
+ """
11
+
12
+ API_VERSION = "v1"
13
+ _BASE = f"/api/{API_VERSION}/cti"
14
+
15
+
16
+ class Endpoints:
17
+ """All CTI platform API endpoint paths."""
18
+
19
+ # ── Calls ─────────────────────────────────────────────────────────────────
20
+ DIAL = f"{_BASE}/calls/dial"
21
+ HANGUP = f"{_BASE}/calls/hangup"
22
+ ANSWER = f"{_BASE}/calls/{{call_id}}/answer"
23
+ FETCH_DETAILS = f"{_BASE}/calls/fetch-call-details"
24
+ ACTIVE_CALLS = f"{_BASE}/calls/active"
25
+
26
+ # ── Hold / Mute / DTMF ───────────────────────────────────────────────────
27
+ HOLD = f"{_BASE}/calls/hold"
28
+ MUTE = f"{_BASE}/calls/mute"
29
+ DTMF = f"{_BASE}/calls/{{call_id}}/dtmf"
30
+
31
+ # ── Transfer ──────────────────────────────────────────────────────────────
32
+ TRANSFER = f"{_BASE}/calls/{{call_id}}/transfer"
33
+ TRANSFER_CALL = f"{_BASE}/calls/transfer-call"
34
+ TRANSFER_CANCEL = f"{_BASE}/calls/transfer/cancel"
35
+ TRANSFER_COMPLETE = f"{_BASE}/calls/transfer/complete"
36
+
37
+ # ── Conference ────────────────────────────────────────────────────────────
38
+ CONFERENCE_ADD = f"{_BASE}/calls/conference/add"
39
+ CONFERENCE_MERGE = f"{_BASE}/calls/conference/merge"
40
+
41
+ # ── Agent ─────────────────────────────────────────────────────────────────
42
+ AGENT_REGISTER = f"{_BASE}/agent/register"
43
+ AGENT_UNREGISTER = f"{_BASE}/agent/unregister"
44
+ AGENT_CONFIG = f"{_BASE}/agent/config"
45
+
46
+ # ── Supervisor ────────────────────────────────────────────────────────────
47
+ SUPERVISOR_LISTEN = f"{_BASE}/supervisor/listen"
48
+ SUPERVISOR_LEAVE = f"{_BASE}/supervisor/leave"
49
+ SUPERVISOR_MODE = f"{_BASE}/supervisor/mode"
50
+
51
+ # ── Data / Lookup ─────────────────────────────────────────────────────────
52
+ DISPOSITIONS = f"{_BASE}/calls/dispositions"
53
+ TRANSFER_TO_DETAILS = f"{_BASE}/transfer-to-details"
54
+ USERS = f"{_BASE}/users"
55
+ HOSPITAL_SERVICES = f"{_BASE}/hospital/services"
56
+ BREAKS = f"{_BASE}/breaks"