nixflex 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.
- nixflex/__init__.py +94 -0
- nixflex/_http.py +147 -0
- nixflex/errors.py +78 -0
- nixflex/py.typed +0 -0
- nixflex/resources/__init__.py +3 -0
- nixflex/resources/account.py +70 -0
- nixflex/resources/agents.py +24 -0
- nixflex/resources/callers.py +26 -0
- nixflex/resources/calls.py +44 -0
- nixflex/resources/phone_numbers.py +36 -0
- nixflex/resources/sms.py +34 -0
- nixflex/webhook.py +51 -0
- nixflex-0.1.0.dist-info/METADATA +67 -0
- nixflex-0.1.0.dist-info/RECORD +16 -0
- nixflex-0.1.0.dist-info/WHEEL +4 -0
- nixflex-0.1.0.dist-info/licenses/LICENSE +9 -0
nixflex/__init__.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Official Nixflex SDK for Python.
|
|
2
|
+
|
|
3
|
+
from nixflex import Nixflex
|
|
4
|
+
client = Nixflex(api_key="nxf_xxx:nxfs_xxx")
|
|
5
|
+
call = client.calls.create(agent_id="agent_x", to_number="+447700900123", prompt="...")
|
|
6
|
+
|
|
7
|
+
Async:
|
|
8
|
+
from nixflex import AsyncNixflex
|
|
9
|
+
async with AsyncNixflex(api_key=...) as client:
|
|
10
|
+
agents = await client.agents.list()
|
|
11
|
+
|
|
12
|
+
Every method mirrors the Node SDK and the CLI - same names in snake_case. The docs
|
|
13
|
+
(docs.nixflex.com) are the source of truth; this SDK changes in the same session as the API.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from ._http import AsyncHttp, SyncHttp, __version__
|
|
22
|
+
from .errors import (
|
|
23
|
+
NixflexAuthenticationError,
|
|
24
|
+
NixflexConnectionError,
|
|
25
|
+
NixflexError,
|
|
26
|
+
NixflexInvalidRequestError,
|
|
27
|
+
NixflexNotFoundError,
|
|
28
|
+
NixflexPaymentRequiredError,
|
|
29
|
+
NixflexRateLimitError,
|
|
30
|
+
NixflexServerError,
|
|
31
|
+
)
|
|
32
|
+
from .resources.account import Keys, Llm, Storage, Tts, Usage, Webhooks
|
|
33
|
+
from .resources.agents import Agents
|
|
34
|
+
from .resources.callers import Callers
|
|
35
|
+
from .resources.calls import Calls, Campaigns
|
|
36
|
+
from .resources.phone_numbers import PhoneNumbers
|
|
37
|
+
from .resources.sms import Sms
|
|
38
|
+
from .webhook import verify_webhook_signature
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"Nixflex", "AsyncNixflex", "verify_webhook_signature", "__version__",
|
|
42
|
+
"NixflexError", "NixflexAuthenticationError", "NixflexPaymentRequiredError", "NixflexNotFoundError",
|
|
43
|
+
"NixflexRateLimitError", "NixflexInvalidRequestError", "NixflexServerError", "NixflexConnectionError",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _Resources:
|
|
48
|
+
def _attach(self, http) -> None:
|
|
49
|
+
self.agents = Agents(http)
|
|
50
|
+
self.calls = Calls(http)
|
|
51
|
+
self.campaigns = Campaigns(http)
|
|
52
|
+
self.phone_numbers = PhoneNumbers(http)
|
|
53
|
+
self.callers = Callers(http)
|
|
54
|
+
self.sms = Sms(http)
|
|
55
|
+
self.keys = Keys(http)
|
|
56
|
+
self.usage = Usage(http)
|
|
57
|
+
self.webhooks = Webhooks(http)
|
|
58
|
+
self.storage = Storage(http)
|
|
59
|
+
self.llm = Llm(http)
|
|
60
|
+
self.tts = Tts(http)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Nixflex(_Resources):
|
|
64
|
+
"""Synchronous client. Use as a context manager or call close() when done."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, api_key: str, base_url: Optional[str] = None, timeout: Optional[float] = None, max_retries: Optional[int] = None, transport: Optional[httpx.BaseTransport] = None):
|
|
67
|
+
self._http = SyncHttp(api_key, base_url, timeout, max_retries, transport)
|
|
68
|
+
self._attach(self._http)
|
|
69
|
+
|
|
70
|
+
def close(self) -> None:
|
|
71
|
+
self._http.close()
|
|
72
|
+
|
|
73
|
+
def __enter__(self) -> "Nixflex":
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
def __exit__(self, *exc) -> None:
|
|
77
|
+
self.close()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AsyncNixflex(_Resources):
|
|
81
|
+
"""Asynchronous client - every method returns an awaitable."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, api_key: str, base_url: Optional[str] = None, timeout: Optional[float] = None, max_retries: Optional[int] = None, transport: Optional[httpx.AsyncBaseTransport] = None):
|
|
84
|
+
self._http = AsyncHttp(api_key, base_url, timeout, max_retries, transport)
|
|
85
|
+
self._attach(self._http)
|
|
86
|
+
|
|
87
|
+
async def close(self) -> None:
|
|
88
|
+
await self._http.close()
|
|
89
|
+
|
|
90
|
+
async def __aenter__(self) -> "AsyncNixflex":
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
async def __aexit__(self, *exc) -> None:
|
|
94
|
+
await self.close()
|
nixflex/_http.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""The core HTTP layer every resource uses - sync and async, one retry policy.
|
|
2
|
+
|
|
3
|
+
Behaviours (identical to the Node SDK, both verified against the live API):
|
|
4
|
+
- Bearer auth with the full key "key_id:key_secret".
|
|
5
|
+
- Per-request timeout (default 30 s).
|
|
6
|
+
- RETRY: a 429 honours the API's Retry-After header (capped 30 s). 5xx and network
|
|
7
|
+
failures retry once with a small backoff. GET/DELETE always retry; POST/PUT/PATCH
|
|
8
|
+
retry ONLY on 429 or a network failure before any response - never after a 5xx,
|
|
9
|
+
which may already have acted (no double calls, no double sends).
|
|
10
|
+
- Every non-2xx becomes a typed error (errors.py); the request id is surfaced.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any, Dict, Mapping, Optional
|
|
18
|
+
from urllib.parse import quote
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from .errors import NixflexConnectionError, error_from_response
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
DEFAULT_BASE_URL = "https://api.nixflex.com"
|
|
26
|
+
DEFAULT_TIMEOUT = 30.0
|
|
27
|
+
_IDEMPOTENT = {"GET", "DELETE"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def enc(number: str) -> str:
|
|
31
|
+
"""E.164 numbers go in URL paths - the + MUST be encoded or routing breaks."""
|
|
32
|
+
return quote(number, safe="")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _check_key(api_key: Any) -> str:
|
|
36
|
+
if not isinstance(api_key, str) or ":" not in api_key:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
'Nixflex: api_key is required in the form "key_id:key_secret" (both parts, joined by a colon). '
|
|
39
|
+
"Find yours at https://dashboard.nixflex.com under API Keys."
|
|
40
|
+
)
|
|
41
|
+
return api_key
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _clean_query(query: Optional[Mapping[str, Any]]) -> Dict[str, str]:
|
|
45
|
+
if not query:
|
|
46
|
+
return {}
|
|
47
|
+
return {k: str(v) for k, v in query.items() if v is not None}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _retry_plan(status: int, method: str, retry_after: int, attempt: int, max_retries: int) -> Optional[float]:
|
|
51
|
+
"""Seconds to sleep before retrying, or None = do not retry."""
|
|
52
|
+
if attempt > max_retries:
|
|
53
|
+
return None
|
|
54
|
+
if status == 429:
|
|
55
|
+
return float(min(retry_after, 30)) or 1.0
|
|
56
|
+
if status >= 500 and method in _IDEMPOTENT:
|
|
57
|
+
return 0.5 * attempt
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse_error(res: httpx.Response):
|
|
62
|
+
request_id = res.headers.get("x-railway-request-id")
|
|
63
|
+
try:
|
|
64
|
+
retry_after = int(res.headers.get("retry-after") or "0")
|
|
65
|
+
except ValueError:
|
|
66
|
+
retry_after = 0
|
|
67
|
+
try:
|
|
68
|
+
body = res.json()
|
|
69
|
+
except Exception:
|
|
70
|
+
body = None
|
|
71
|
+
return request_id, retry_after, body
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _Base:
|
|
75
|
+
def __init__(self, api_key: str, base_url: Optional[str], timeout: Optional[float], max_retries: Optional[int]):
|
|
76
|
+
self._api_key = _check_key(api_key)
|
|
77
|
+
self._base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
78
|
+
self._timeout = DEFAULT_TIMEOUT if timeout is None else timeout
|
|
79
|
+
self._max_retries = 1 if max_retries is None else max_retries
|
|
80
|
+
self._headers = {
|
|
81
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
82
|
+
"Content-Type": "application/json",
|
|
83
|
+
"User-Agent": f"nixflex-python/{__version__}",
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
def _url(self, path: str) -> str:
|
|
87
|
+
return self._base_url + "/v1" + path
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class SyncHttp(_Base):
|
|
91
|
+
def __init__(self, api_key: str, base_url: Optional[str] = None, timeout: Optional[float] = None, max_retries: Optional[int] = None, transport: Optional[httpx.BaseTransport] = None):
|
|
92
|
+
super().__init__(api_key, base_url, timeout, max_retries)
|
|
93
|
+
self._client = httpx.Client(timeout=self._timeout, transport=transport)
|
|
94
|
+
|
|
95
|
+
def close(self) -> None:
|
|
96
|
+
self._client.close()
|
|
97
|
+
|
|
98
|
+
def request(self, method: str, path: str, body: Any = None, query: Optional[Mapping[str, Any]] = None, timeout: Optional[float] = None) -> Any:
|
|
99
|
+
attempt = 0
|
|
100
|
+
content = None if body is None else json.dumps(body).encode("utf-8")
|
|
101
|
+
while True:
|
|
102
|
+
attempt += 1
|
|
103
|
+
try:
|
|
104
|
+
res = self._client.request(method, self._url(path), params=_clean_query(query), content=content, headers=self._headers, timeout=self._timeout if timeout is None else timeout)
|
|
105
|
+
except (httpx.TransportError, httpx.TimeoutException) as err:
|
|
106
|
+
if attempt <= self._max_retries:
|
|
107
|
+
time.sleep(0.3 * attempt)
|
|
108
|
+
continue
|
|
109
|
+
raise NixflexConnectionError(f"Could not reach the Nixflex API ({err.__class__.__name__}: {err}). Check connectivity and https://nixflex.com/status") from None
|
|
110
|
+
if res.is_success:
|
|
111
|
+
return None if res.status_code == 204 else res.json()
|
|
112
|
+
request_id, retry_after, err_body = _parse_error(res)
|
|
113
|
+
wait = _retry_plan(res.status_code, method, retry_after, attempt, self._max_retries)
|
|
114
|
+
if wait is not None:
|
|
115
|
+
time.sleep(wait)
|
|
116
|
+
continue
|
|
117
|
+
raise error_from_response(res.status_code, err_body, request_id, retry_after)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class AsyncHttp(_Base):
|
|
121
|
+
def __init__(self, api_key: str, base_url: Optional[str] = None, timeout: Optional[float] = None, max_retries: Optional[int] = None, transport: Optional[httpx.AsyncBaseTransport] = None):
|
|
122
|
+
super().__init__(api_key, base_url, timeout, max_retries)
|
|
123
|
+
self._client = httpx.AsyncClient(timeout=self._timeout, transport=transport)
|
|
124
|
+
|
|
125
|
+
async def close(self) -> None:
|
|
126
|
+
await self._client.aclose()
|
|
127
|
+
|
|
128
|
+
async def request(self, method: str, path: str, body: Any = None, query: Optional[Mapping[str, Any]] = None, timeout: Optional[float] = None) -> Any:
|
|
129
|
+
attempt = 0
|
|
130
|
+
content = None if body is None else json.dumps(body).encode("utf-8")
|
|
131
|
+
while True:
|
|
132
|
+
attempt += 1
|
|
133
|
+
try:
|
|
134
|
+
res = await self._client.request(method, self._url(path), params=_clean_query(query), content=content, headers=self._headers, timeout=self._timeout if timeout is None else timeout)
|
|
135
|
+
except (httpx.TransportError, httpx.TimeoutException) as err:
|
|
136
|
+
if attempt <= self._max_retries:
|
|
137
|
+
await asyncio.sleep(0.3 * attempt)
|
|
138
|
+
continue
|
|
139
|
+
raise NixflexConnectionError(f"Could not reach the Nixflex API ({err.__class__.__name__}: {err}). Check connectivity and https://nixflex.com/status") from None
|
|
140
|
+
if res.is_success:
|
|
141
|
+
return None if res.status_code == 204 else res.json()
|
|
142
|
+
request_id, retry_after, err_body = _parse_error(res)
|
|
143
|
+
wait = _retry_plan(res.status_code, method, retry_after, attempt, self._max_retries)
|
|
144
|
+
if wait is not None:
|
|
145
|
+
await asyncio.sleep(wait)
|
|
146
|
+
continue
|
|
147
|
+
raise error_from_response(res.status_code, err_body, request_id, retry_after)
|
nixflex/errors.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Typed error family. Mirrors the API's error shape exactly:
|
|
2
|
+
{ "error": { "type", "code", "message", "doc_url", "details" } }
|
|
3
|
+
Every non-2xx response becomes one of these - catch by class:
|
|
4
|
+
try: ...
|
|
5
|
+
except NixflexRateLimitError as e: sleep(e.retry_after_seconds)
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NixflexError(Exception):
|
|
13
|
+
"""Base class. `status` is the HTTP status (0 for network/timeout failures)."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, status: int, body: Optional[Dict[str, Any]], request_id: Optional[str] = None, fallback_message: Optional[str] = None):
|
|
16
|
+
e = (body or {}).get("error") or {}
|
|
17
|
+
super().__init__(e.get("message") or fallback_message or f"Nixflex API error (HTTP {status})")
|
|
18
|
+
self.status: int = status
|
|
19
|
+
self.code: str = e.get("code") or "unknown_error"
|
|
20
|
+
self.type: str = e.get("type") or "error"
|
|
21
|
+
self.doc_url: Optional[str] = e.get("doc_url")
|
|
22
|
+
self.details: Dict[str, Any] = e.get("details") or {}
|
|
23
|
+
self.request_id: Optional[str] = request_id
|
|
24
|
+
|
|
25
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
26
|
+
return f"{self.__class__.__name__}(status={self.status}, code={self.code!r}, message={str(self)!r})"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NixflexAuthenticationError(NixflexError):
|
|
30
|
+
"""401 - missing or invalid API key."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NixflexPaymentRequiredError(NixflexError):
|
|
34
|
+
"""402 - balance or credit exhausted."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NixflexNotFoundError(NixflexError):
|
|
38
|
+
"""404 - the resource does not exist (or is not yours)."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NixflexInvalidRequestError(NixflexError):
|
|
42
|
+
"""400/422 - the request itself is malformed or invalid."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NixflexServerError(NixflexError):
|
|
46
|
+
"""5xx - something failed on Nixflex's side. GET/DELETE are retried once automatically."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NixflexRateLimitError(NixflexError):
|
|
50
|
+
"""429 - rate limit hit. `retry_after_seconds` says when to try again."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, status: int, body: Optional[Dict[str, Any]], request_id: Optional[str], retry_after_seconds: int):
|
|
53
|
+
super().__init__(status, body, request_id)
|
|
54
|
+
self.retry_after_seconds: int = retry_after_seconds
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class NixflexConnectionError(NixflexError):
|
|
58
|
+
"""Network failure / timeout - the request never got an HTTP response."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, message: str):
|
|
61
|
+
super().__init__(0, None, None, message)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def error_from_response(status: int, body: Optional[Dict[str, Any]], request_id: Optional[str], retry_after_seconds: int) -> NixflexError:
|
|
65
|
+
"""Map a status + body to the right error class (same table as the Node SDK)."""
|
|
66
|
+
if status == 401:
|
|
67
|
+
return NixflexAuthenticationError(status, body, request_id)
|
|
68
|
+
if status == 402:
|
|
69
|
+
return NixflexPaymentRequiredError(status, body, request_id)
|
|
70
|
+
if status == 404:
|
|
71
|
+
return NixflexNotFoundError(status, body, request_id)
|
|
72
|
+
if status == 429:
|
|
73
|
+
return NixflexRateLimitError(status, body, request_id, retry_after_seconds)
|
|
74
|
+
if status in (400, 422):
|
|
75
|
+
return NixflexInvalidRequestError(status, body, request_id)
|
|
76
|
+
if status >= 500:
|
|
77
|
+
return NixflexServerError(status, body, request_id)
|
|
78
|
+
return NixflexError(status, body, request_id)
|
nixflex/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any
|
|
3
|
+
from .._http import enc
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Keys:
|
|
7
|
+
def __init__(self, http):
|
|
8
|
+
self._http = http
|
|
9
|
+
|
|
10
|
+
def rotate(self):
|
|
11
|
+
"""New secret. The current one stops working immediately - update every integration."""
|
|
12
|
+
return self._http.request("POST", "/keys/rotate")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Usage:
|
|
16
|
+
def __init__(self, http):
|
|
17
|
+
self._http = http
|
|
18
|
+
|
|
19
|
+
def get(self):
|
|
20
|
+
return self._http.request("GET", "/usage")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _ByoBase:
|
|
24
|
+
"""Bring your own storage / LLM / TTS. set() is verified by the API with a real probe before saving; get() never returns secrets."""
|
|
25
|
+
_path = ""
|
|
26
|
+
|
|
27
|
+
def __init__(self, http):
|
|
28
|
+
self._http = http
|
|
29
|
+
|
|
30
|
+
def set(self, **params: Any):
|
|
31
|
+
return self._http.request("PUT", self._path, params)
|
|
32
|
+
|
|
33
|
+
def get(self):
|
|
34
|
+
return self._http.request("GET", self._path)
|
|
35
|
+
|
|
36
|
+
def delete(self):
|
|
37
|
+
"""Disconnect - calls fall back to Nixflex."""
|
|
38
|
+
return self._http.request("DELETE", self._path)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Storage(_ByoBase):
|
|
42
|
+
_path = "/account/storage"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Llm(_ByoBase):
|
|
46
|
+
_path = "/account/llm"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Tts(_ByoBase):
|
|
50
|
+
_path = "/account/tts"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Webhooks:
|
|
54
|
+
"""Per-number post-call webhook (two slots). Verify deliveries with nixflex.verify_webhook_signature."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, http):
|
|
57
|
+
self._http = http
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def _base(slot: int) -> str:
|
|
61
|
+
return "webhook2" if slot == 2 else "webhook"
|
|
62
|
+
|
|
63
|
+
def set(self, phone_number: str, url: str, slot: int = 1):
|
|
64
|
+
return self._http.request("PUT", f"/integrations/{self._base(slot)}/number/{enc(phone_number)}", {"url": url})
|
|
65
|
+
|
|
66
|
+
def get(self, phone_number: str, slot: int = 1):
|
|
67
|
+
return self._http.request("GET", f"/integrations/{self._base(slot)}/number/{enc(phone_number)}")
|
|
68
|
+
|
|
69
|
+
def delete(self, phone_number: str, slot: int = 1):
|
|
70
|
+
return self._http.request("DELETE", f"/integrations/{self._base(slot)}/number/{enc(phone_number)}")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Dict, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Agents:
|
|
6
|
+
def __init__(self, http):
|
|
7
|
+
self._http = http
|
|
8
|
+
|
|
9
|
+
def create(self, **params: Any):
|
|
10
|
+
"""Create an agent. Any field the API accepts (name, system_prompt, voice_id, language, incall_sms_enabled, ...)."""
|
|
11
|
+
return self._http.request("POST", "/agents", params)
|
|
12
|
+
|
|
13
|
+
def list(self, limit: Optional[int] = None, offset: Optional[int] = None):
|
|
14
|
+
return self._http.request("GET", "/agents", None, {"limit": limit, "offset": offset})
|
|
15
|
+
|
|
16
|
+
def get(self, agent_id: str):
|
|
17
|
+
return self._http.request("GET", f"/agents/{agent_id}")
|
|
18
|
+
|
|
19
|
+
def update(self, agent_id: str, **params: Any):
|
|
20
|
+
"""Only the fields you pass change."""
|
|
21
|
+
return self._http.request("PUT", f"/agents/{agent_id}", params)
|
|
22
|
+
|
|
23
|
+
def delete(self, agent_id: str):
|
|
24
|
+
return self._http.request("DELETE", f"/agents/{agent_id}")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Dict, List
|
|
3
|
+
from .._http import enc
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Callers:
|
|
7
|
+
"""Caller context - what the agent knows about a caller on one of your numbers.
|
|
8
|
+
phone_number is YOUR number; caller_number is the customer. Docs: /concepts/caller-context"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, http):
|
|
11
|
+
self._http = http
|
|
12
|
+
|
|
13
|
+
def get(self, phone_number: str, caller_number: str):
|
|
14
|
+
return self._http.request("GET", f"/phone-numbers/{enc(phone_number)}/callers/{enc(caller_number)}")
|
|
15
|
+
|
|
16
|
+
def set(self, phone_number: str, caller_number: str, **fields: Any):
|
|
17
|
+
"""name, email, phone, location, reference_id, preference. Omitted fields are kept; pass None to remove one. last_call / open_item are engine-only (400 engine_only_field)."""
|
|
18
|
+
return self._http.request("PUT", f"/phone-numbers/{enc(phone_number)}/callers/{enc(caller_number)}", fields)
|
|
19
|
+
|
|
20
|
+
def delete(self, phone_number: str, caller_number: str):
|
|
21
|
+
"""Erases the whole record - yours and the engine's."""
|
|
22
|
+
return self._http.request("DELETE", f"/phone-numbers/{enc(phone_number)}/callers/{enc(caller_number)}")
|
|
23
|
+
|
|
24
|
+
def import_(self, phone_number: str, callers: List[Dict[str, Any]]):
|
|
25
|
+
"""Up to 1,000 rows, each {"caller_number": "+44...", ...fields}. Every row is validated before any is written."""
|
|
26
|
+
return self._http.request("POST", f"/phone-numbers/{enc(phone_number)}/callers/import", {"callers": callers})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Calls:
|
|
6
|
+
def __init__(self, http):
|
|
7
|
+
self._http = http
|
|
8
|
+
|
|
9
|
+
def create(self, agent_id: str, to_number: str, prompt: str, from_number: Optional[str] = None, variables: Optional[Dict[str, str]] = None, **extra: Any):
|
|
10
|
+
"""Place an outbound call now. Never retried after a 5xx - a call is never dialled twice."""
|
|
11
|
+
body: Dict[str, Any] = {"agent_id": agent_id, "to_number": to_number, "prompt": prompt, **extra}
|
|
12
|
+
if from_number is not None:
|
|
13
|
+
body["from_number"] = from_number
|
|
14
|
+
if variables is not None:
|
|
15
|
+
body["variables"] = variables
|
|
16
|
+
return self._http.request("POST", "/calls/outbound", body)
|
|
17
|
+
|
|
18
|
+
def list(self, limit: Optional[int] = None, offset: Optional[int] = None, agent_id: Optional[str] = None):
|
|
19
|
+
return self._http.request("GET", "/calls", None, {"limit": limit, "offset": offset, "agent_id": agent_id})
|
|
20
|
+
|
|
21
|
+
def get(self, call_id: str):
|
|
22
|
+
return self._http.request("GET", f"/calls/{call_id}")
|
|
23
|
+
|
|
24
|
+
def delete(self, call_id: str):
|
|
25
|
+
"""Removes the record and its recording (GDPR)."""
|
|
26
|
+
return self._http.request("DELETE", f"/calls/{call_id}")
|
|
27
|
+
|
|
28
|
+
def delete_all(self):
|
|
29
|
+
"""Every call, recording and SMS record on the account. Cannot be undone."""
|
|
30
|
+
return self._http.request("DELETE", "/calls")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Campaigns:
|
|
34
|
+
"""Voice batch campaigns - many calls under one prompt."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, http):
|
|
37
|
+
self._http = http
|
|
38
|
+
|
|
39
|
+
def create(self, agent_id: str, from_number: str, prompt: str, recipients: List[Dict[str, Any]], **extra: Any):
|
|
40
|
+
"""recipients: [{"phone": "+44...", "variables": {...}, "prompt_override": "..."}, ...]. Dials immediately unless schedule_type="schedule"."""
|
|
41
|
+
return self._http.request("POST", "/calls/batch", {"agent_id": agent_id, "from_number": from_number, "prompt": prompt, "recipients": recipients, **extra})
|
|
42
|
+
|
|
43
|
+
def launch(self, campaign_id: str):
|
|
44
|
+
return self._http.request("POST", f"/calls/batch/{campaign_id}/launch")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
from .._http import enc
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PhoneNumbers:
|
|
7
|
+
def __init__(self, http):
|
|
8
|
+
self._http = http
|
|
9
|
+
|
|
10
|
+
def import_(self, phone_number: str, agent_id: str, **credentials: Any):
|
|
11
|
+
"""Attach a number you own. Twilio: twilio_account_sid + twilio_auth_token. Telnyx: telnyx_api_key + telnyx_connection_id. The carrier is inferred from which you send."""
|
|
12
|
+
return self._http.request("POST", "/phone-numbers", {"phone_number": phone_number, "agent_id": agent_id, **credentials})
|
|
13
|
+
|
|
14
|
+
def list(self, agent_id: Optional[str] = None):
|
|
15
|
+
return self._http.request("GET", "/phone-numbers", None, {"agent_id": agent_id})
|
|
16
|
+
|
|
17
|
+
def update(self, phone_number: str, **params: Any):
|
|
18
|
+
"""Per-number settings: custom_prompt, sms_prompt, sms_reply_enabled, web_prompt, dtmf_enabled, record_call, voice_id, speaking_rate, ... Only passed fields change; None clears where the API allows null."""
|
|
19
|
+
return self._http.request("PATCH", f"/phone-numbers/{enc(phone_number)}", params)
|
|
20
|
+
|
|
21
|
+
def delete(self, phone_number: str):
|
|
22
|
+
"""Disconnects from Nixflex. Your carrier keeps the number and keeps billing it."""
|
|
23
|
+
return self._http.request("DELETE", f"/phone-numbers/{enc(phone_number)}")
|
|
24
|
+
|
|
25
|
+
def set_monitor(self, phone_number: str, enabled: bool):
|
|
26
|
+
"""Live call monitoring. On bills that number's inbound calls at the premium rate."""
|
|
27
|
+
return self._http.request("PUT", f"/integrations/monitor/number/{enc(phone_number)}", {"enabled": enabled})
|
|
28
|
+
|
|
29
|
+
def get_monitor(self, phone_number: str):
|
|
30
|
+
return self._http.request("GET", f"/integrations/monitor/number/{enc(phone_number)}")
|
|
31
|
+
|
|
32
|
+
def set_web_calls(self, phone_number: str, enabled: bool):
|
|
33
|
+
return self._http.request("PUT", f"/integrations/web-calls/number/{enc(phone_number)}", {"enabled": enabled})
|
|
34
|
+
|
|
35
|
+
def get_web_calls(self, phone_number: str):
|
|
36
|
+
return self._http.request("GET", f"/integrations/web-calls/number/{enc(phone_number)}")
|
nixflex/resources/sms.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SmsCampaigns:
|
|
6
|
+
def __init__(self, http):
|
|
7
|
+
self._http = http
|
|
8
|
+
|
|
9
|
+
def create(self, agent_id: str, from_number: str, message: str, recipients: List[Dict[str, Any]], **extra: Any):
|
|
10
|
+
"""recipients: [{"phone": "+44...", "variables": {...}}, ...]. from_number may be on either carrier."""
|
|
11
|
+
return self._http.request("POST", "/sms/campaigns", {"agent_id": agent_id, "from_number": from_number, "message": message, "recipients": recipients, **extra})
|
|
12
|
+
|
|
13
|
+
def launch(self, campaign_id: str):
|
|
14
|
+
return self._http.request("POST", f"/sms/campaigns/{campaign_id}/launch")
|
|
15
|
+
|
|
16
|
+
def list(self, status: Optional[str] = None, limit: Optional[int] = None):
|
|
17
|
+
return self._http.request("GET", "/sms/campaigns", None, {"status": status, "limit": limit})
|
|
18
|
+
|
|
19
|
+
def get(self, campaign_id: str):
|
|
20
|
+
return self._http.request("GET", f"/sms/campaigns/{campaign_id}")
|
|
21
|
+
|
|
22
|
+
def delete(self, campaign_id: str):
|
|
23
|
+
"""Pending recipients are not messaged. Sent messages cannot be recalled."""
|
|
24
|
+
return self._http.request("DELETE", f"/sms/campaigns/{campaign_id}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Sms:
|
|
28
|
+
def __init__(self, http):
|
|
29
|
+
self._http = http
|
|
30
|
+
self.campaigns = SmsCampaigns(http)
|
|
31
|
+
|
|
32
|
+
def send(self, agent_id: str, from_number: str, to: str, message: str, **extra: Any):
|
|
33
|
+
"""One text from one of your numbers. Under 600 characters delivers reliably."""
|
|
34
|
+
return self._http.request("POST", "/sms", {"agent_id": agent_id, "from_number": from_number, "to": to, "message": message, **extra})
|
nixflex/webhook.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Verify Nixflex webhook signatures.
|
|
2
|
+
|
|
3
|
+
The engine signs every delivery:
|
|
4
|
+
header X-Nixflex-Signature: t=<unix_ts>,v1=<hmac_sha256_hex>
|
|
5
|
+
payload "<timestamp>.<raw_body>" secret: your key_secret
|
|
6
|
+
|
|
7
|
+
Use the RAW request body BYTES exactly as received - a re-serialised JSON body may
|
|
8
|
+
differ from what was signed and will fail verification. Never raises.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
from typing import Optional, Union
|
|
17
|
+
|
|
18
|
+
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def verify_webhook_signature(
|
|
22
|
+
raw_body: Union[bytes, str],
|
|
23
|
+
signature_header: Optional[str],
|
|
24
|
+
key_secret: str,
|
|
25
|
+
tolerance_seconds: int = 300,
|
|
26
|
+
now: Optional[int] = None,
|
|
27
|
+
) -> bool:
|
|
28
|
+
"""True only if the signature is authentic AND fresh (within `tolerance_seconds`)."""
|
|
29
|
+
try:
|
|
30
|
+
if not signature_header or not key_secret:
|
|
31
|
+
return False
|
|
32
|
+
parts = {}
|
|
33
|
+
for seg in signature_header.split(","):
|
|
34
|
+
i = seg.find("=")
|
|
35
|
+
if i > 0:
|
|
36
|
+
parts[seg[:i].strip()] = seg[i + 1:].strip()
|
|
37
|
+
try:
|
|
38
|
+
t = int(parts.get("t", ""))
|
|
39
|
+
except ValueError:
|
|
40
|
+
return False
|
|
41
|
+
v1 = parts.get("v1", "")
|
|
42
|
+
if not _HEX64.match(v1):
|
|
43
|
+
return False
|
|
44
|
+
current = int(time.time()) if now is None else now
|
|
45
|
+
if abs(current - t) > tolerance_seconds:
|
|
46
|
+
return False # stale or future-dated = replay risk
|
|
47
|
+
body = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
|
|
48
|
+
expected = hmac.new(key_secret.encode("utf-8"), f"{t}.".encode("utf-8") + body, hashlib.sha256).hexdigest()
|
|
49
|
+
return hmac.compare_digest(expected, v1) # constant-time
|
|
50
|
+
except Exception:
|
|
51
|
+
return False
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: nixflex
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Nixflex voice AI platform - AI phone agents, outbound campaigns, SMS and caller context.
|
|
5
|
+
Project-URL: Homepage, https://nixflex.com
|
|
6
|
+
Project-URL: Documentation, https://docs.nixflex.com/sdks/python
|
|
7
|
+
Project-URL: Repository, https://github.com/nixflex/nixflex-python
|
|
8
|
+
Project-URL: Changelog, https://github.com/nixflex/nixflex-python/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Nixflex <stackadvisor.app@gmail.com>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai-receptionist,nixflex,phone,sdk,sms,telephony,telnyx,twilio,voice-agent,voice-ai
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Communications :: Telephony
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Requires-Dist: httpx<2,>=0.27
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# nixflex
|
|
32
|
+
|
|
33
|
+
Official Python SDK for the [Nixflex](https://nixflex.com) voice AI platform - AI phone agents, outbound campaigns, SMS and caller context.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install nixflex
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from nixflex import Nixflex
|
|
41
|
+
|
|
42
|
+
client = Nixflex(api_key="nxf_xxx:nxfs_xxx") # both halves, joined by a colon
|
|
43
|
+
|
|
44
|
+
call = client.calls.create(
|
|
45
|
+
agent_id="agent_15d1a9ee16294087",
|
|
46
|
+
to_number="+447700900123",
|
|
47
|
+
prompt="Call Sam to confirm his appointment on Tuesday at 11.",
|
|
48
|
+
)
|
|
49
|
+
print(call["call_id"])
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Async:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from nixflex import AsyncNixflex
|
|
56
|
+
|
|
57
|
+
async with AsyncNixflex(api_key="nxf_xxx:nxfs_xxx") as client:
|
|
58
|
+
agents = await client.agents.list()
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- Every endpoint: `agents`, `calls`, `campaigns`, `phone_numbers`, `callers` (caller context), `sms` (+ `sms.campaigns`), `keys`, `usage`, `webhooks`, `storage`, `llm`, `tts` - the same names as the Node SDK and the CLI.
|
|
62
|
+
- Retries: a 429 is retried once honouring `Retry-After`; network failures retry once; 5xx retries only `GET`/`DELETE`. A `POST` is never blind-retried - a call is never dialled twice.
|
|
63
|
+
- Typed errors: `NixflexAuthenticationError`, `NixflexRateLimitError` (`.retry_after_seconds`), `NixflexNotFoundError`, ...
|
|
64
|
+
- `verify_webhook_signature(raw_body, header, key_secret)` for signed webhooks.
|
|
65
|
+
- Python 3.9+, one dependency (`httpx`).
|
|
66
|
+
|
|
67
|
+
Docs: https://docs.nixflex.com/sdks/python
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
nixflex/__init__.py,sha256=cbUtZFZAsHIcIul8zfODSARsE8h3RyIM5y1yh1QkAe8,3220
|
|
2
|
+
nixflex/_http.py,sha256=K19kiHyZR2JmHTbjiB2644s5Hq_xNgqwu63cDOQbtwE,6603
|
|
3
|
+
nixflex/errors.py,sha256=kUKXBvjOZIj2lGs0dtnWkn1gNAjdr6-QP3iftycz8vU,3176
|
|
4
|
+
nixflex/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
nixflex/webhook.py,sha256=tV0g7o5l5H8p7qqzSRlVMo_wtBSSAp-oL-ECoyLCdN8,1754
|
|
6
|
+
nixflex/resources/__init__.py,sha256=awIDLUyHUB7n6gVQiK6qOWJfvRLd3BTSELehjPreGpI,240
|
|
7
|
+
nixflex/resources/account.py,sha256=LhstNgC-nWwztcaA60ccyA84MgmMI_AIQK0YbDdM-XA,1956
|
|
8
|
+
nixflex/resources/agents.py,sha256=7lR-xwPtPgUuYXLfxO8czJTsQypk4wOAhgInZTdP6Sk,920
|
|
9
|
+
nixflex/resources/callers.py,sha256=qmBu-K37aEAtWXw2iv6UT1ZHLHBVClxQetsvocriBtY,1445
|
|
10
|
+
nixflex/resources/calls.py,sha256=I-zWKSTuPIVi28q4kWQJZ26_NxHgUN579zLH4CqfdP8,2083
|
|
11
|
+
nixflex/resources/phone_numbers.py,sha256=wDaPisGYJKYagtYeLt8Kr5HsIO0CS1ltZsQBtYgVuFU,2017
|
|
12
|
+
nixflex/resources/sms.py,sha256=C7FyxmBIYVPj718Ww9Al-rmSZy0cHNyOLWXV4RfR_WA,1608
|
|
13
|
+
nixflex-0.1.0.dist-info/METADATA,sha256=XW5aBsxnzsnaogCl79ZivQVKjfwSOlOVPBpteAraX98,2700
|
|
14
|
+
nixflex-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
15
|
+
nixflex-0.1.0.dist-info/licenses/LICENSE,sha256=g_QzaorUR1ATbaF52Su9hwGjlYBDYEZ1QltkeyiS_OM,1080
|
|
16
|
+
nixflex-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nixflex Enterprises LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|