voxcore-sdk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ # Secrets. .env holds SECRET_KEY and the field-encryption key-ring; losing it
2
+ # is bad, committing it is worse. .env.example is the template that IS tracked.
3
+ .env
4
+ .env.local
5
+ .env*.local
6
+
7
+ # The deployment env dumps. Same contents as .env and then some: PROD-ENV.txt
8
+ # carries FIELD_ENCRYPTION_KEYS next to DB_PASSWORD, which together decrypt
9
+ # every encrypted column in production, plus the Stripe, Telnyx, Sendblue and
10
+ # model-provider keys. Matched by name anywhere in the tree rather than by path,
11
+ # because the next copy of it will be somewhere else.
12
+ PROD-ENV.txt
13
+ prod-env.txt
14
+ *prod-env*.txt
15
+ *PROD-ENV*.txt
16
+
17
+ # Python
18
+ backend/venv/
19
+ __pycache__/
20
+ *.py[cod]
21
+ backend/db.sqlite3
22
+ # The test runner's database, left behind when a run is interrupted.
23
+ backend/test_db.sqlite3
24
+ *.sqlite3
25
+ backend/staticfiles/
26
+ backend/media/
27
+
28
+ # Node
29
+ node_modules/
30
+ frontend/.next/
31
+ frontend/out/
32
+ frontend/.turbo/
33
+ *.tsbuildinfo
34
+ next-env.d.ts
35
+
36
+ # Editors / OS
37
+ .DS_Store
38
+ Thumbs.db
39
+ .idea/
40
+ .vscode/
@@ -0,0 +1,173 @@
1
+ Metadata-Version: 2.5
2
+ Name: voxcore-sdk
3
+ Version: 0.1.0
4
+ Summary: The official Python client for VOXCORE — AI agents that answer your phone, text your customers, and talk to your website visitors.
5
+ Project-URL: Homepage, https://voxcore.net
6
+ Project-URL: Documentation, https://voxcore.net/docs
7
+ Project-URL: Source, https://github.com/CodeCraftStudios/voxcore-python
8
+ Author-email: CodeCraft Studios <support@voxcore.net>
9
+ License-Expression: MIT
10
+ Keywords: agents,ai,sms,telephony,voice,voxcore
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Communications :: Telephony
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.9
23
+ Requires-Dist: httpx<1,>=0.24
24
+ Provides-Extra: dev
25
+ Requires-Dist: mypy>=1.5; extra == 'dev'
26
+ Requires-Dist: pytest>=7; extra == 'dev'
27
+ Requires-Dist: respx>=0.20; extra == 'dev'
28
+ Requires-Dist: ruff>=0.5; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # VOXCORE for Python
32
+
33
+ The official Python client for [VOXCORE](https://voxcore.net) — AI agents that answer your phone, text your customers, and talk to visitors on your website.
34
+
35
+ ```bash
36
+ pip install voxcore-sdk
37
+ ```
38
+
39
+ The distribution is `voxcore-sdk`; what you import is `voxcore`.
40
+
41
+ ## Getting started
42
+
43
+ You need a **secret key** and your **organization id**. Both come from your dashboard: keys under Settings → API keys, and the org id (`org__…`) from the URL.
44
+
45
+ ```python
46
+ from voxcore import Voxcore
47
+
48
+ vox = Voxcore(api_key="vox_sk_live_…", org="org__…")
49
+
50
+ for contact in vox.contacts.iterate():
51
+ print(contact["display_name"])
52
+ ```
53
+
54
+ Or leave them out and set `VOXCORE_API_KEY` and `VOXCORE_ORG` in the environment, which is where a key belongs — not in a file that ends up in a commit:
55
+
56
+ ```python
57
+ vox = Voxcore()
58
+ ```
59
+
60
+ The client holds a connection pool, so reuse one rather than making a new one per call. It works as a context manager if you want the pool closed:
61
+
62
+ ```python
63
+ with Voxcore() as vox:
64
+ vox.agents.list()
65
+ ```
66
+
67
+ ## Listing things
68
+
69
+ Every collection has the same five verbs — `list`, `iterate`, `retrieve`, `create`, `update`, `delete` — and two ways to read:
70
+
71
+ ```python
72
+ vox.contacts.list(status="customer") # the first page, as a list
73
+ vox.contacts.iterate(status="customer") # every page, as a lazy iterator
74
+ ```
75
+
76
+ `list()` returns **one page**. That is deliberate: a tenant with forty thousand contacts should not discover that `list()` quietly made sixteen hundred requests. Use `iterate()` when you want all of them — it follows the API's cursor, so rows arriving while you read do not shift the pages under you.
77
+
78
+ ```python
79
+ consented = [
80
+ c for c in vox.contacts.iterate()
81
+ if c["marketing_consent_message"]
82
+ ]
83
+ ```
84
+
85
+ ## Writing things
86
+
87
+ ```python
88
+ contact = vox.contacts.create(
89
+ first_name="Dana",
90
+ phone="+18175550147", # E.164, always
91
+ status="lead",
92
+ )
93
+
94
+ vox.contacts.update(contact["id"], status="customer")
95
+ ```
96
+
97
+ ### Retries and idempotency
98
+
99
+ `GET`s are retried automatically on connection failures, 429s and 5xx, with backoff.
100
+
101
+ **Writes are not**, unless you make them safe. Repeating a `POST` can create a second contact or place a second call, so the client will not do it on its own. Pass an idempotency key and it will:
102
+
103
+ ```python
104
+ vox.contacts.create(first_name="Dana", idempotency_key=True) # generated
105
+ vox.contacts.create(first_name="Dana", idempotency_key="order-8127") # yours
106
+ ```
107
+
108
+ Use your own key when you have a natural one — an order id, a row id — because that is what makes the retry safe across process restarts too.
109
+
110
+ Sending a text is keyed by default. Texting somebody twice is worse than the alternative:
111
+
112
+ ```python
113
+ vox.threads.send("thr__…", "Your order is ready.")
114
+ ```
115
+
116
+ ## Errors
117
+
118
+ Everything inherits from `VoxcoreError`, so one `except` catches everything this library raises and nothing it does not.
119
+
120
+ ```python
121
+ from voxcore import Voxcore, AuthenticationError, PermissionDenied, RateLimitError
122
+
123
+ try:
124
+ vox.contacts.list()
125
+ except AuthenticationError:
126
+ ... # the key is wrong, expired or revoked — retrying will never help
127
+ except PermissionDenied:
128
+ ... # the key is fine and lacks a scope — fix it in the dashboard
129
+ except RateLimitError as e:
130
+ ... # retries were already exhausted; e.retry_after is seconds
131
+ ```
132
+
133
+ Every error carries `.code` (VOXCORE's machine-readable string — branch on this, it is more stable than the HTTP status), `.message`, `.status` and `.request_id`. Quote the request id in a support conversation; it is how a specific request is found in the logs.
134
+
135
+ ## What you can reach
136
+
137
+ | | |
138
+ |---|---|
139
+ | `vox.agents` | Agents — what they say, how they sound. Also `.publish()` |
140
+ | `vox.calls` | Calls in and out, and `.transcript()` |
141
+ | `vox.campaigns` | Outbound calling campaigns. `.start()`, `.pause()`, `.attempts()` |
142
+ | `vox.contacts` | The people you talk to. `.find_by_phone()` |
143
+ | `vox.contact_batches` | Named audiences. `.members()`, `.add()`, `.exclude()` |
144
+ | `vox.threads` | Texting conversations. `.send()` |
145
+ | `vox.phone_numbers` | Your lines |
146
+ | `vox.widgets`, `vox.forms`, `vox.flows` | Website widgets, forms, messaging sequences |
147
+ | `vox.meetings`, `vox.meeting_types` | Booking |
148
+ | `vox.keys` | API keys |
149
+
150
+ Anything not wrapped yet is still reachable, and that is on purpose — an SDK that lags the API by a release is an SDK that blocks you:
151
+
152
+ ```python
153
+ vox.request("GET", "/api/v1/orgs/org__…/something-new")
154
+ ```
155
+
156
+ ## Two things worth knowing
157
+
158
+ **Consent is read-only.** `marketing_consent_call`, `marketing_consent_message` and `marketing_consent_email` can be read but never written through the API. They record what a customer actually said — with a timestamp, the call it came from, and a verbatim quote — and a field a script can set is not evidence that anybody said anything. The opt-out flags (`do_not_call`, `do_not_message`) *are* writable, because someone ringing to ask to be removed has to be recordable by whoever takes the call.
159
+
160
+ **Publishing is not saving.** Updating an agent does not change what callers hear. A live conversation pins the agent's published version, so changes reach the phone only when you publish:
161
+
162
+ ```python
163
+ vox.agents.update(agent_id, system_prompt="…")
164
+ vox.agents.publish(agent_id, notes="new opening line")
165
+ ```
166
+
167
+ ## Requirements
168
+
169
+ Python 3.9+. One dependency: `httpx`.
170
+
171
+ ## License
172
+
173
+ MIT
@@ -0,0 +1,143 @@
1
+ # VOXCORE for Python
2
+
3
+ The official Python client for [VOXCORE](https://voxcore.net) — AI agents that answer your phone, text your customers, and talk to visitors on your website.
4
+
5
+ ```bash
6
+ pip install voxcore-sdk
7
+ ```
8
+
9
+ The distribution is `voxcore-sdk`; what you import is `voxcore`.
10
+
11
+ ## Getting started
12
+
13
+ You need a **secret key** and your **organization id**. Both come from your dashboard: keys under Settings → API keys, and the org id (`org__…`) from the URL.
14
+
15
+ ```python
16
+ from voxcore import Voxcore
17
+
18
+ vox = Voxcore(api_key="vox_sk_live_…", org="org__…")
19
+
20
+ for contact in vox.contacts.iterate():
21
+ print(contact["display_name"])
22
+ ```
23
+
24
+ Or leave them out and set `VOXCORE_API_KEY` and `VOXCORE_ORG` in the environment, which is where a key belongs — not in a file that ends up in a commit:
25
+
26
+ ```python
27
+ vox = Voxcore()
28
+ ```
29
+
30
+ The client holds a connection pool, so reuse one rather than making a new one per call. It works as a context manager if you want the pool closed:
31
+
32
+ ```python
33
+ with Voxcore() as vox:
34
+ vox.agents.list()
35
+ ```
36
+
37
+ ## Listing things
38
+
39
+ Every collection has the same five verbs — `list`, `iterate`, `retrieve`, `create`, `update`, `delete` — and two ways to read:
40
+
41
+ ```python
42
+ vox.contacts.list(status="customer") # the first page, as a list
43
+ vox.contacts.iterate(status="customer") # every page, as a lazy iterator
44
+ ```
45
+
46
+ `list()` returns **one page**. That is deliberate: a tenant with forty thousand contacts should not discover that `list()` quietly made sixteen hundred requests. Use `iterate()` when you want all of them — it follows the API's cursor, so rows arriving while you read do not shift the pages under you.
47
+
48
+ ```python
49
+ consented = [
50
+ c for c in vox.contacts.iterate()
51
+ if c["marketing_consent_message"]
52
+ ]
53
+ ```
54
+
55
+ ## Writing things
56
+
57
+ ```python
58
+ contact = vox.contacts.create(
59
+ first_name="Dana",
60
+ phone="+18175550147", # E.164, always
61
+ status="lead",
62
+ )
63
+
64
+ vox.contacts.update(contact["id"], status="customer")
65
+ ```
66
+
67
+ ### Retries and idempotency
68
+
69
+ `GET`s are retried automatically on connection failures, 429s and 5xx, with backoff.
70
+
71
+ **Writes are not**, unless you make them safe. Repeating a `POST` can create a second contact or place a second call, so the client will not do it on its own. Pass an idempotency key and it will:
72
+
73
+ ```python
74
+ vox.contacts.create(first_name="Dana", idempotency_key=True) # generated
75
+ vox.contacts.create(first_name="Dana", idempotency_key="order-8127") # yours
76
+ ```
77
+
78
+ Use your own key when you have a natural one — an order id, a row id — because that is what makes the retry safe across process restarts too.
79
+
80
+ Sending a text is keyed by default. Texting somebody twice is worse than the alternative:
81
+
82
+ ```python
83
+ vox.threads.send("thr__…", "Your order is ready.")
84
+ ```
85
+
86
+ ## Errors
87
+
88
+ Everything inherits from `VoxcoreError`, so one `except` catches everything this library raises and nothing it does not.
89
+
90
+ ```python
91
+ from voxcore import Voxcore, AuthenticationError, PermissionDenied, RateLimitError
92
+
93
+ try:
94
+ vox.contacts.list()
95
+ except AuthenticationError:
96
+ ... # the key is wrong, expired or revoked — retrying will never help
97
+ except PermissionDenied:
98
+ ... # the key is fine and lacks a scope — fix it in the dashboard
99
+ except RateLimitError as e:
100
+ ... # retries were already exhausted; e.retry_after is seconds
101
+ ```
102
+
103
+ Every error carries `.code` (VOXCORE's machine-readable string — branch on this, it is more stable than the HTTP status), `.message`, `.status` and `.request_id`. Quote the request id in a support conversation; it is how a specific request is found in the logs.
104
+
105
+ ## What you can reach
106
+
107
+ | | |
108
+ |---|---|
109
+ | `vox.agents` | Agents — what they say, how they sound. Also `.publish()` |
110
+ | `vox.calls` | Calls in and out, and `.transcript()` |
111
+ | `vox.campaigns` | Outbound calling campaigns. `.start()`, `.pause()`, `.attempts()` |
112
+ | `vox.contacts` | The people you talk to. `.find_by_phone()` |
113
+ | `vox.contact_batches` | Named audiences. `.members()`, `.add()`, `.exclude()` |
114
+ | `vox.threads` | Texting conversations. `.send()` |
115
+ | `vox.phone_numbers` | Your lines |
116
+ | `vox.widgets`, `vox.forms`, `vox.flows` | Website widgets, forms, messaging sequences |
117
+ | `vox.meetings`, `vox.meeting_types` | Booking |
118
+ | `vox.keys` | API keys |
119
+
120
+ Anything not wrapped yet is still reachable, and that is on purpose — an SDK that lags the API by a release is an SDK that blocks you:
121
+
122
+ ```python
123
+ vox.request("GET", "/api/v1/orgs/org__…/something-new")
124
+ ```
125
+
126
+ ## Two things worth knowing
127
+
128
+ **Consent is read-only.** `marketing_consent_call`, `marketing_consent_message` and `marketing_consent_email` can be read but never written through the API. They record what a customer actually said — with a timestamp, the call it came from, and a verbatim quote — and a field a script can set is not evidence that anybody said anything. The opt-out flags (`do_not_call`, `do_not_message`) *are* writable, because someone ringing to ask to be removed has to be recordable by whoever takes the call.
129
+
130
+ **Publishing is not saving.** Updating an agent does not change what callers hear. A live conversation pins the agent's published version, so changes reach the phone only when you publish:
131
+
132
+ ```python
133
+ vox.agents.update(agent_id, system_prompt="…")
134
+ vox.agents.publish(agent_id, notes="new opening line")
135
+ ```
136
+
137
+ ## Requirements
138
+
139
+ Python 3.9+. One dependency: `httpx`.
140
+
141
+ ## License
142
+
143
+ MIT
@@ -0,0 +1,55 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ # The distribution is "voxcore-sdk"; the import stays "voxcore".
7
+ #
8
+ # PyPI refused "voxcore": it normalises to the same name as "vox-core", which
9
+ # is already registered to somebody else, so it is not available at any point
10
+ # in the future either. The import name is ours regardless, which is the half
11
+ # that appears in every line of code somebody writes. Same arrangement as
12
+ # python-dateutil -> dateutil.
13
+ name = "voxcore-sdk"
14
+ dynamic = ["version"]
15
+ description = "The official Python client for VOXCORE — AI agents that answer your phone, text your customers, and talk to your website visitors."
16
+ readme = "README.md"
17
+ requires-python = ">=3.9"
18
+ license = "MIT"
19
+ authors = [{ name = "CodeCraft Studios", email = "support@voxcore.net" }]
20
+ keywords = ["voxcore", "voice", "ai", "telephony", "sms", "agents"]
21
+ classifiers = [
22
+ "Development Status :: 4 - Beta",
23
+ "Intended Audience :: Developers",
24
+ "License :: OSI Approved :: MIT License",
25
+ "Programming Language :: Python :: 3",
26
+ "Programming Language :: Python :: 3.9",
27
+ "Programming Language :: Python :: 3.10",
28
+ "Programming Language :: Python :: 3.11",
29
+ "Programming Language :: Python :: 3.12",
30
+ "Programming Language :: Python :: 3.13",
31
+ "Topic :: Communications :: Telephony",
32
+ "Typing :: Typed",
33
+ ]
34
+ dependencies = ["httpx>=0.24,<1"]
35
+
36
+ [project.urls]
37
+ Homepage = "https://voxcore.net"
38
+ Documentation = "https://voxcore.net/docs"
39
+ Source = "https://github.com/CodeCraftStudios/voxcore-python"
40
+
41
+ [project.optional-dependencies]
42
+ dev = ["pytest>=7", "respx>=0.20", "mypy>=1.5", "ruff>=0.5"]
43
+
44
+ [tool.hatch.version]
45
+ path = "src/voxcore/__about__.py"
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/voxcore"]
49
+
50
+ [tool.ruff]
51
+ line-length = 88
52
+ target-version = "py39"
53
+
54
+ [tool.pytest.ini_options]
55
+ testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,41 @@
1
+ """
2
+ VOXCORE for Python.
3
+
4
+ from voxcore import Voxcore
5
+
6
+ vox = Voxcore()
7
+ for contact in vox.contacts.iterate(status="customer"):
8
+ print(contact["display_name"])
9
+
10
+ Everything is reached through ``Voxcore``. Errors all inherit from
11
+ ``VoxcoreError``, so one except clause catches everything this library raises
12
+ and nothing it does not.
13
+ """
14
+
15
+ from .__about__ import __version__
16
+ from .client import Voxcore
17
+ from .errors import (
18
+ APIError,
19
+ AuthenticationError,
20
+ ConnectionError,
21
+ NotFoundError,
22
+ PermissionDenied,
23
+ RateLimitError,
24
+ ServerError,
25
+ ValidationError,
26
+ VoxcoreError,
27
+ )
28
+
29
+ __all__ = [
30
+ "Voxcore",
31
+ "VoxcoreError",
32
+ "APIError",
33
+ "AuthenticationError",
34
+ "PermissionDenied",
35
+ "NotFoundError",
36
+ "ValidationError",
37
+ "RateLimitError",
38
+ "ServerError",
39
+ "ConnectionError",
40
+ "__version__",
41
+ ]
@@ -0,0 +1,242 @@
1
+ """
2
+ The one place a request is made, and the only place retries are decided.
3
+
4
+ Everything in ``resources/`` is a thin call into ``Transport.request``. That is
5
+ deliberate: retry policy, error mapping, idempotency and the auth header are
6
+ each the kind of rule that is correct once and then wrong at whichever call
7
+ site forgot it, so no resource is allowed its own.
8
+
9
+ **What is retried, and what is not.** A GET is safe to repeat, so connection
10
+ failures, 429s and 5xx are retried with backoff. A POST is not -- repeating one
11
+ can create a second contact or place a second call -- so it is retried only
12
+ when the caller supplied an idempotency key, which is what makes the repeat
13
+ safe on the server. Retrying an unkeyed POST is how one request becomes two
14
+ phone calls to the same person.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import random
20
+ import time
21
+ import uuid
22
+ from typing import Any, Iterator, Mapping
23
+ from urllib.parse import urlparse
24
+
25
+ import httpx
26
+
27
+ from . import errors
28
+ from .__about__ import __version__
29
+
30
+ DEFAULT_BASE_URL = "https://api.voxcore.net"
31
+ DEFAULT_TIMEOUT = 30.0
32
+ DEFAULT_MAX_RETRIES = 2
33
+
34
+ # Repeated with backoff. 408 and 409 are absent on purpose: a timeout the
35
+ # server reported may have completed, and a conflict is a state problem that a
36
+ # second identical request cannot fix.
37
+ RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
38
+
39
+ SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
40
+
41
+
42
+ class Transport:
43
+ """HTTP, auth, retries and error mapping. Not part of the public API."""
44
+
45
+ def __init__(
46
+ self,
47
+ *,
48
+ api_key: str,
49
+ base_url: str = DEFAULT_BASE_URL,
50
+ timeout: float = DEFAULT_TIMEOUT,
51
+ max_retries: int = DEFAULT_MAX_RETRIES,
52
+ http_client: httpx.Client | None = None,
53
+ ) -> None:
54
+ if not api_key:
55
+ raise errors.VoxcoreError(
56
+ "An API key is required. Pass api_key=..., or set VOXCORE_API_KEY."
57
+ )
58
+
59
+ self._api_key = api_key
60
+ self._base_url = base_url.rstrip("/")
61
+ self._max_retries = max(0, int(max_retries))
62
+ self._owns_client = http_client is None
63
+ self._client = http_client or httpx.Client(timeout=timeout)
64
+
65
+ # ---- lifecycle -------------------------------------------------------
66
+
67
+ def close(self) -> None:
68
+ # Only what we opened. A caller who passed their own pooled client
69
+ # expects it to still work after this object goes away.
70
+ if self._owns_client:
71
+ self._client.close()
72
+
73
+ # ---- the request -----------------------------------------------------
74
+
75
+ def request(
76
+ self,
77
+ method: str,
78
+ path: str,
79
+ *,
80
+ params: Mapping[str, Any] | None = None,
81
+ json: Any = None,
82
+ idempotency_key: str | None = None,
83
+ ) -> Any:
84
+ url = path if path.startswith("http") else f"{self._base_url}{path}"
85
+ method = method.upper()
86
+
87
+ headers = {
88
+ "Authorization": f"Bearer {self._api_key}",
89
+ "Accept": "application/json",
90
+ "User-Agent": f"voxcore-python/{__version__}",
91
+ }
92
+ if idempotency_key:
93
+ headers["Idempotency-Key"] = idempotency_key
94
+
95
+ # A keyed write is safe to repeat; an unkeyed one is not. See the
96
+ # module docstring -- this single line is the whole policy.
97
+ retryable = method in SAFE_METHODS or bool(idempotency_key)
98
+ attempts = self._max_retries + 1 if retryable else 1
99
+
100
+ last_error: Exception | None = None
101
+
102
+ for attempt in range(attempts):
103
+ if attempt:
104
+ time.sleep(self._backoff(attempt, last_error))
105
+
106
+ try:
107
+ response = self._client.request(
108
+ method,
109
+ url,
110
+ params=self._clean(params),
111
+ json=json,
112
+ headers=headers,
113
+ )
114
+ except httpx.TimeoutException as failure:
115
+ last_error = errors.ConnectionError(
116
+ f"The request to {url} timed out."
117
+ )
118
+ last_error.__cause__ = failure
119
+ continue
120
+ except httpx.HTTPError as failure:
121
+ last_error = errors.ConnectionError(f"Could not reach {url}: {failure}")
122
+ last_error.__cause__ = failure
123
+ continue
124
+
125
+ if response.status_code in RETRY_STATUSES and attempt < attempts - 1:
126
+ last_error = self._to_error(response)
127
+ continue
128
+
129
+ if response.status_code >= 400:
130
+ raise self._to_error(response)
131
+
132
+ if response.status_code == 204 or not response.content:
133
+ return None
134
+ try:
135
+ return response.json()
136
+ except ValueError as failure:
137
+ raise errors.APIError(
138
+ "The API returned a response that was not JSON.",
139
+ status=response.status_code,
140
+ request_id=response.headers.get("X-Request-Id", ""),
141
+ body=response.text[:500],
142
+ ) from failure
143
+
144
+ raise last_error or errors.ConnectionError(f"Could not reach {url}.")
145
+
146
+ # ---- pagination ------------------------------------------------------
147
+
148
+ def paginate(
149
+ self, path: str, *, params: Mapping[str, Any] | None = None
150
+ ) -> Iterator[dict]:
151
+ """
152
+ Every item across every page, one at a time.
153
+
154
+ Follows ``page.next``, which is a full URL the API builds, rather than
155
+ incrementing an offset. VOXCORE paginates on a cursor precisely because
156
+ an offset shifts when a row is added mid-read, and reconstructing the
157
+ next request here instead of following the given link would put that
158
+ bug back.
159
+ """
160
+ query: Mapping[str, Any] | None = params
161
+ url = path
162
+
163
+ while url:
164
+ payload = self.request("GET", url, params=query)
165
+ if not isinstance(payload, dict):
166
+ return
167
+
168
+ for item in payload.get("data") or []:
169
+ yield item
170
+
171
+ page = payload.get("page") or {}
172
+ url = page.get("next") or ""
173
+ # The next link already carries the cursor and the limit; sending
174
+ # the original params again would override the cursor with the
175
+ # first page's and loop forever.
176
+ query = None
177
+
178
+ # ---- helpers ---------------------------------------------------------
179
+
180
+ def _to_error(self, response: httpx.Response) -> errors.APIError:
181
+ try:
182
+ body = response.json()
183
+ except ValueError:
184
+ body = response.text[:500]
185
+
186
+ failure = errors.from_response(
187
+ response.status_code, body, response.headers.get("X-Request-Id", "")
188
+ )
189
+ if isinstance(failure, errors.RateLimitError):
190
+ header = response.headers.get("Retry-After")
191
+ if header:
192
+ try:
193
+ failure.retry_after = float(header)
194
+ except ValueError:
195
+ pass
196
+ return failure
197
+
198
+ def _backoff(self, attempt: int, last_error: Exception | None) -> float:
199
+ """
200
+ Exponential, with jitter, and the server's own number when it gave one.
201
+
202
+ The jitter is not decoration. Clients that back off on identical
203
+ schedules retry in lockstep and arrive together, which is how a service
204
+ that was recovering is knocked over by its own users.
205
+ """
206
+ if isinstance(last_error, errors.RateLimitError) and last_error.retry_after:
207
+ return min(float(last_error.retry_after), 60.0)
208
+ return min(0.5 * (2 ** (attempt - 1)), 8.0) * (1 + random.random() * 0.25)
209
+
210
+ @staticmethod
211
+ def _clean(params: Mapping[str, Any] | None) -> dict | None:
212
+ """
213
+ Drop None, and render booleans the way the API reads them.
214
+
215
+ ``None`` means "the caller did not pass this", and forwarding it as the
216
+ string "None" turns an omitted filter into a filter for the literal
217
+ word. Python's ``True`` would go over as "True", which Django's query
218
+ parsing does not read as true.
219
+ """
220
+ if not params:
221
+ return None
222
+ out = {}
223
+ for key, value in params.items():
224
+ if value is None:
225
+ continue
226
+ if isinstance(value, bool):
227
+ out[key] = "true" if value else "false"
228
+ elif isinstance(value, (list, tuple)):
229
+ out[key] = ",".join(str(v) for v in value)
230
+ else:
231
+ out[key] = value
232
+ return out or None
233
+
234
+
235
+ def new_idempotency_key() -> str:
236
+ """A key for a write the caller wants to be safe to retry."""
237
+ return f"vox-{uuid.uuid4()}"
238
+
239
+
240
+ def looks_like_production(base_url: str) -> bool:
241
+ host = (urlparse(base_url).hostname or "").lower()
242
+ return host.endswith("voxcore.net")