primate-intelligence 0.1.0__tar.gz → 0.3.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,39 @@
1
+ # Changelog — primate-intelligence (Python SDK)
2
+
3
+ ## 0.3.0 — 2026-07-31
4
+
5
+ Parity release with the PRI-496 private→public API parity program. Behavior verified against the live dev API on 2026-07-31 (recorded stream with narrative + status events + mid-stream update_prompt). Dict-based responses — fully additive.
6
+
7
+ ### Added
8
+
9
+ - `client.streams.recording(stream_id)` — `GET /v1/streams/{id}/recording`: signed 1h playback URL for the server-side session recording (create the stream with `recording=True`; raw H.264 Annex-B — `ffmpeg -i in.h264 -c copy out.mp4`).
10
+ - `client.videos.list_analyses(video_id, status=, limit=, starting_after=)` — `GET /v1/videos/{id}/analyses`: per-video analysis history, newest first.
11
+ - `client.analyses.rerun(analysis_id)` — `POST /v1/analyses/{id}/rerun`: free re-run of an eligible failed analysis (`rerun_eligible: true`; never billed).
12
+
13
+ ### Documentation (docstrings)
14
+
15
+ - `streams.create`: full signaling protocol summary incl. mid-stream `update_prompt` → `prompt_updated`, the narrative opt-in (`options={"narrative": True}` → `narrative_update {t_s, text}` on result detections + `status` events `prompt_context | combined_prompt | recalculating`), and the `recording=True` opt-in.
16
+ - `analyses.create`: the narrative opt-in and the async `narrative {status, entries}` object on completed analyses.
17
+ - `videos.retrieve`: the `media {url, expires_at}` signed source-playback field (sign-on-read, 1h TTL).
18
+
19
+ ## 0.2.0 — 2026-07-28
20
+
21
+ Parity release with the 2026-07-22 → 2026-07-28 API changes (PRI-480/482 rounds + streaming DX). Behavior verified against the live OpenAPI document and recorded live responses. Dict-based responses mean no breaking type changes — this release is additive.
22
+
23
+ ### Added
24
+
25
+ - `client.credits.retrieve(limit=, before=)` — `GET /v1/credits`: balance in billable seconds + paginated transaction ledger (each debit carries its analysis/stream `source_id`). Check before submitting work to avoid a 402.
26
+ - `client.credit_pricing.retrieve()` — `GET /v1/credit-pricing` (public): `price_per_second_cents`, grants, purchase presets.
27
+ - `client.analyses.validate_only(video_id=, prompt=)` — dry-run (`validate_only: true`): returns an `analysis_preview` with `assessable`, `estimated_seconds`, `estimated_cost_usd`. Costs nothing.
28
+ - `client.analyses.create_batch(video_id=, prompts=[...])` — `POST /v1/analyses/batch`: 2–10 prompts, first full price, each additional at 50%. Returns `analysis_batch` with all analyses + pricing summary.
29
+ - `client.analyses.validate_batch(...)` — batch dry-run returning `analysis_batch_preview` with per-prompt `discount_pct` (0 or 50) and estimates.
30
+ - Auto `Idempotency-Key` now also covers `POST /v1/analyses/batch`.
31
+
32
+ ### Documentation (docstrings — dict responses, no code change needed)
33
+
34
+ - `create_and_wait` / `create`: documents `result.detected_count` (count-intent queries; 0 = nothing found), `result.indeterminate_reason` (`low_confidence | nothing_detected | unsupported_query_form | duration_mismatch`), nullable `result.video_duration_s`, and the real `usage` shape `{billed_seconds, credit_balance_after}` (immutable post-settlement snapshot).
35
+ - `streams.end`: documents the honest `end_reason` vocabulary (`completed | canceled | insufficient_credits | error | timeout | ice_failed | media_timeout`), `results_summary.result_frames` (the `frames` alias is removed ~2026-08-28), `failure_diagnostic`, and the metering-tick field `session_remaining_s` (the `balance_s` alias is removed ~2026-08-28).
36
+
37
+ ## 0.1.0
38
+
39
+ Initial release.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: primate-intelligence
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Official Python SDK for the Primate Vision API (Primate Intelligence). Video scene understanding: upload, analyze, stream.
5
5
  Project-URL: Homepage, https://primateintelligence.ai/docs
6
6
  Project-URL: Repository, https://github.com/Primate-Intelligence/primate-intelligence-api
@@ -57,12 +57,15 @@ curl -X POST https://api.primateintelligence.ai/v1/sandbox
57
57
 
58
58
  ## Features
59
59
 
60
- - **Typed resource namespaces** for the full `/v1` surface: `videos`, `analyses`, `streams`, `client_tokens`, `models`, `usage`, `errors`, `webhook_endpoints`.
60
+ - **Typed resource namespaces** for the full `/v1` surface: `videos`, `analyses`, `streams`, `client_tokens`, `models`, `usage`, `credits`, `credit_pricing`, `errors`, `webhook_endpoints`.
61
61
  - **Automatic retries** driven by the [error registry](https://primateintelligence.ai/docs/errors) `retryable` flags + `Retry-After`.
62
62
  - **Auto idempotency keys** (UUIDv4) on all create calls.
63
63
  - **`create_and_wait()`** — create an analysis and block until a terminal state (uses `Prefer: wait` server-side, then polls).
64
64
  - **`videos.upload()`** — create + presigned S3 PUT + complete, in one call.
65
65
  - **Pagination iterators** — `for video in client.videos.iter(): …`.
66
+ - **`analyses.validate_only()`** — dry-run a prompt: compiled query + `assessable` + cost estimate, without creating an analysis or spending credits.
67
+ - **`analyses.create_batch()` / `validate_batch()`** — 2–10 prompts against one video; first full price, each additional at 50%.
68
+ - **`credits.retrieve()`** — balance + per-analysis transaction ledger (`GET /v1/credits`); **`credit_pricing.retrieve()`** — public pricing config.
66
69
  - **Webhook verification** — `verify_webhook()` implements [Standard Webhooks](https://www.standardwebhooks.com) with constant-time compare + replay-window checks.
67
70
 
68
71
  ## Error handling
@@ -76,7 +79,7 @@ try:
76
79
  except PrimateError as err:
77
80
  print(err.code, err.status, err.request_id, err.docs_url)
78
81
  if err.code == "insufficient_credits":
79
- usage = client.usage.retrieve()
82
+ credits = client.credits.retrieve() # balance_seconds + transaction ledger
80
83
  # → prompt the account owner to top up or enable auto-refill
81
84
  ```
82
85
 
@@ -35,12 +35,15 @@ curl -X POST https://api.primateintelligence.ai/v1/sandbox
35
35
 
36
36
  ## Features
37
37
 
38
- - **Typed resource namespaces** for the full `/v1` surface: `videos`, `analyses`, `streams`, `client_tokens`, `models`, `usage`, `errors`, `webhook_endpoints`.
38
+ - **Typed resource namespaces** for the full `/v1` surface: `videos`, `analyses`, `streams`, `client_tokens`, `models`, `usage`, `credits`, `credit_pricing`, `errors`, `webhook_endpoints`.
39
39
  - **Automatic retries** driven by the [error registry](https://primateintelligence.ai/docs/errors) `retryable` flags + `Retry-After`.
40
40
  - **Auto idempotency keys** (UUIDv4) on all create calls.
41
41
  - **`create_and_wait()`** — create an analysis and block until a terminal state (uses `Prefer: wait` server-side, then polls).
42
42
  - **`videos.upload()`** — create + presigned S3 PUT + complete, in one call.
43
43
  - **Pagination iterators** — `for video in client.videos.iter(): …`.
44
+ - **`analyses.validate_only()`** — dry-run a prompt: compiled query + `assessable` + cost estimate, without creating an analysis or spending credits.
45
+ - **`analyses.create_batch()` / `validate_batch()`** — 2–10 prompts against one video; first full price, each additional at 50%.
46
+ - **`credits.retrieve()`** — balance + per-analysis transaction ledger (`GET /v1/credits`); **`credit_pricing.retrieve()`** — public pricing config.
44
47
  - **Webhook verification** — `verify_webhook()` implements [Standard Webhooks](https://www.standardwebhooks.com) with constant-time compare + replay-window checks.
45
48
 
46
49
  ## Error handling
@@ -54,7 +57,7 @@ try:
54
57
  except PrimateError as err:
55
58
  print(err.code, err.status, err.request_id, err.docs_url)
56
59
  if err.code == "insufficient_credits":
57
- usage = client.usage.retrieve()
60
+ credits = client.credits.retrieve() # balance_seconds + transaction ledger
58
61
  # → prompt the account owner to top up or enable auto-refill
59
62
  ```
60
63
 
@@ -23,6 +23,8 @@ from ._errors import PrimateError, PrimateTimeoutError, registry_flags
23
23
  from ._resources import (
24
24
  Analyses,
25
25
  ClientTokens,
26
+ CreditPricing,
27
+ Credits,
26
28
  Errors,
27
29
  Models,
28
30
  Streams,
@@ -33,7 +35,7 @@ from ._resources import (
33
35
  )
34
36
  from .webhooks import verify_webhook, verify_webhook_request
35
37
 
36
- __version__ = "0.1.0"
38
+ __version__ = "0.2.0"
37
39
  __all__ = [
38
40
  "Primate",
39
41
  "PrimateError",
@@ -68,6 +70,8 @@ class Primate:
68
70
  self.client_tokens = ClientTokens(self.http)
69
71
  self.models = Models(self.http)
70
72
  self.usage = Usage(self.http)
73
+ self.credits = Credits(self.http)
74
+ self.credit_pricing = CreditPricing(self.http)
71
75
  self.errors = Errors(self.http)
72
76
  self.webhook_endpoints = WebhookEndpoints(self.http)
73
77
  self.test_fixtures = TestFixtures(self.http)
@@ -17,8 +17,8 @@ import httpx
17
17
  from ._errors import PrimateError
18
18
 
19
19
  DEFAULT_BASE_URL = "https://api.primateintelligence.ai"
20
- _IDEMPOTENT_CREATE_PATHS = ("/v1/videos", "/v1/analyses", "/v1/webhook_endpoints")
21
- _USER_AGENT = "primate-intelligence-sdk-py/0.1.0"
20
+ _IDEMPOTENT_CREATE_PATHS = ("/v1/videos", "/v1/analyses", "/v1/analyses/batch", "/v1/webhook_endpoints")
21
+ _USER_AGENT = "primate-intelligence-sdk-py/0.2.0"
22
22
 
23
23
 
24
24
  class HttpClient:
@@ -50,6 +50,13 @@
50
50
  "idem": false,
51
51
  "docs_url": "https://primateintelligence.ai/docs/errors#test_mode_only"
52
52
  },
53
+ "test_key_fixture_only": {
54
+ "kind": "http",
55
+ "status": 403,
56
+ "retryable": false,
57
+ "idem": false,
58
+ "docs_url": "https://primateintelligence.ai/docs/errors#test_key_fixture_only"
59
+ },
53
60
  "validation_failed": {
54
61
  "kind": "http",
55
62
  "status": 400,
@@ -197,6 +204,13 @@
197
204
  "idem": false,
198
205
  "docs_url": "https://primateintelligence.ai/docs/errors#analysis_not_cancelable"
199
206
  },
207
+ "rerun_not_eligible": {
208
+ "kind": "http",
209
+ "status": 409,
210
+ "retryable": false,
211
+ "idem": false,
212
+ "docs_url": "https://primateintelligence.ai/docs/errors#rerun_not_eligible"
213
+ },
200
214
  "rate_limit_exceeded": {
201
215
  "kind": "http",
202
216
  "status": 429,
@@ -225,6 +239,13 @@
225
239
  "idem": false,
226
240
  "docs_url": "https://primateintelligence.ai/docs/errors#insufficient_credits"
227
241
  },
242
+ "grant_exhausted": {
243
+ "kind": "http",
244
+ "status": 402,
245
+ "retryable": false,
246
+ "idem": false,
247
+ "docs_url": "https://primateintelligence.ai/docs/errors#grant_exhausted"
248
+ },
228
249
  "capacity_exhausted": {
229
250
  "kind": "http",
230
251
  "status": 503,
@@ -239,6 +260,41 @@
239
260
  "idem": true,
240
261
  "docs_url": "https://primateintelligence.ai/docs/errors#sandbox_limit_exceeded"
241
262
  },
263
+ "upgrade_limit_exceeded": {
264
+ "kind": "http",
265
+ "status": 429,
266
+ "retryable": false,
267
+ "idem": false,
268
+ "docs_url": "https://primateintelligence.ai/docs/errors#upgrade_limit_exceeded"
269
+ },
270
+ "github_verification_required": {
271
+ "kind": "http",
272
+ "status": 403,
273
+ "retryable": false,
274
+ "idem": false,
275
+ "docs_url": "https://primateintelligence.ai/docs/errors#github_verification_required"
276
+ },
277
+ "github_token_invalid": {
278
+ "kind": "http",
279
+ "status": 401,
280
+ "retryable": false,
281
+ "idem": false,
282
+ "docs_url": "https://primateintelligence.ai/docs/errors#github_token_invalid"
283
+ },
284
+ "github_account_already_used": {
285
+ "kind": "http",
286
+ "status": 409,
287
+ "retryable": false,
288
+ "idem": false,
289
+ "docs_url": "https://primateintelligence.ai/docs/errors#github_account_already_used"
290
+ },
291
+ "device_code_expired": {
292
+ "kind": "http",
293
+ "status": 410,
294
+ "retryable": false,
295
+ "idem": false,
296
+ "docs_url": "https://primateintelligence.ai/docs/errors#device_code_expired"
297
+ },
242
298
  "inference_unavailable": {
243
299
  "kind": "http",
244
300
  "status": 503,
@@ -246,6 +302,13 @@
246
302
  "idem": true,
247
303
  "docs_url": "https://primateintelligence.ai/docs/errors#inference_unavailable"
248
304
  },
305
+ "upstream_error": {
306
+ "kind": "http",
307
+ "status": 502,
308
+ "retryable": false,
309
+ "idem": false,
310
+ "docs_url": "https://primateintelligence.ai/docs/errors#upstream_error"
311
+ },
249
312
  "db_unavailable": {
250
313
  "kind": "http",
251
314
  "status": 503,
@@ -0,0 +1,419 @@
1
+ """Typed resource namespaces — mirror the /v1 surface 1:1.
2
+
3
+ Responses are returned as dicts matching the OpenAPI schemas
4
+ (https://api.primateintelligence.ai/v1/openapi.json). Structured so a
5
+ Stainless-generated client can replace this module without changing the
6
+ public import surface (A6 fallback path, PRI-438).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from typing import Any, Iterator, Optional
12
+
13
+ import httpx
14
+
15
+ from ._client import HttpClient
16
+ from ._errors import PrimateTimeoutError
17
+
18
+ _TERMINAL = {"completed", "failed", "canceled"}
19
+
20
+
21
+ class Videos:
22
+ def __init__(self, http: HttpClient) -> None:
23
+ self._http = http
24
+
25
+ def create(self, **params: Any) -> dict[str, Any]:
26
+ """POST /v1/videos — presigned-upload create or URL-ingest create."""
27
+ return self._http.post("/v1/videos", json_body=params)
28
+
29
+ def retrieve(self, video_id: str) -> dict[str, Any]:
30
+ """GET /v1/videos/{id}.
31
+
32
+ Ready videos carry ``media: {url, expires_at} | None`` — a signed
33
+ playback URL for the original source video, generated FRESH on every
34
+ read with a 1-hour TTL (sign-on-read). Re-fetch the video for a new
35
+ URL after expiry; old videos always stay playable. ``media`` is None
36
+ for non-ready videos and rows without a stored source object (e.g.
37
+ sandbox fixture videos).
38
+ """
39
+ return self._http.get(f"/v1/videos/{video_id}")
40
+
41
+ def list(self, **params: Any) -> dict[str, Any]:
42
+ return self._http.get("/v1/videos", params=params)
43
+
44
+ def iter(self, **params: Any) -> Iterator[dict[str, Any]]:
45
+ return self._http.paginate("/v1/videos", params=params)
46
+
47
+ def delete(self, video_id: str) -> dict[str, Any]:
48
+ return self._http.delete(f"/v1/videos/{video_id}")
49
+
50
+ def complete(self, video_id: str) -> dict[str, Any]:
51
+ """POST /v1/videos/{id}/complete — after the presigned S3 PUT."""
52
+ return self._http.post(f"/v1/videos/{video_id}/complete")
53
+
54
+ def list_analyses(self, video_id: str, **params: Any) -> dict[str, Any]:
55
+ """GET /v1/videos/{id}/analyses — per-video analysis history.
56
+
57
+ Every analysis run against this video, newest first. Same list
58
+ envelope + filters (``status``, ``limit``, ``starting_after``) as
59
+ GET /v1/analyses — the resource-nested spelling of
60
+ ``analyses.list(video_id=...)``. Combine with ``Video.media`` (signed
61
+ source playback) and ``Analysis.artifacts`` (annotated result video)
62
+ for full video-library semantics: list, replay, per-video history.
63
+ """
64
+ return self._http.get(f"/v1/videos/{video_id}/analyses", params=params)
65
+
66
+ def upload(
67
+ self,
68
+ file: bytes,
69
+ *,
70
+ filename: str,
71
+ content_type: str = "video/mp4",
72
+ metadata: Optional[dict[str, str]] = None,
73
+ ) -> dict[str, Any]:
74
+ """Create + presigned S3 PUT + complete, in one call."""
75
+ video = self.create(
76
+ filename=filename,
77
+ content_type=content_type,
78
+ size_bytes=len(file),
79
+ **({"metadata": metadata} if metadata else {}),
80
+ )
81
+ upload = video.get("upload")
82
+ if not upload:
83
+ raise RuntimeError("API did not return presigned upload details")
84
+ res = httpx.put(upload["url"], content=file, headers=upload.get("headers", {}), timeout=600)
85
+ res.raise_for_status()
86
+ return self.complete(video["id"])
87
+
88
+
89
+ class Analyses:
90
+ def __init__(self, http: HttpClient) -> None:
91
+ self._http = http
92
+
93
+ def create(self, **params: Any) -> dict[str, Any]:
94
+ """POST /v1/analyses.
95
+
96
+ Pass ``options={"narrative": True}`` to generate a timestamped
97
+ narrative (event sentences) for the video: the completed analysis
98
+ then carries ``narrative: {status: "generating" | "ready" | "failed",
99
+ entries: [{t_s, text}]}``. Generation runs asynchronously AFTER
100
+ completion — the first completed read may show ``generating`` with
101
+ empty entries; keep polling the GET until ``ready``. No surcharge.
102
+ Without the opt-in, ``narrative`` is always None.
103
+
104
+ Pass ``validate_only=True`` for a dry run: compiles the prompt, reports
105
+ whether the query is assessable, and estimates duration/cost — WITHOUT
106
+ creating an analysis or spending credits. The response is then an
107
+ ``analysis_preview`` object (``{object, query, parse_mode, assessable,
108
+ video_duration_s, estimated_seconds, estimated_cost_usd}``), not an
109
+ analysis. Prefer :meth:`validate_only` which reads more clearly.
110
+ """
111
+ return self._http.post("/v1/analyses", json_body=params)
112
+
113
+ def validate_only(self, **params: Any) -> dict[str, Any]:
114
+ """Dry-run a prompt (POST /v1/analyses with ``validate_only: true``).
115
+
116
+ Costs nothing: no analysis is created, no credits spent. Returns an
117
+ ``analysis_preview`` dict::
118
+
119
+ {object: "analysis_preview", query, parse_mode: "llm"|"heuristic"|"client",
120
+ assessable: bool, video_duration_s: float|None,
121
+ estimated_seconds: int|None, estimated_cost_usd: float|None}
122
+
123
+ Estimates are None when the video duration is not yet known.
124
+ """
125
+ params["validate_only"] = True
126
+ return self._http.post("/v1/analyses", json_body=params)
127
+
128
+ def create_batch(self, **params: Any) -> dict[str, Any]:
129
+ """POST /v1/analyses/batch — 2–10 prompts against the same video.
130
+
131
+ First prompt is billed at full price; each additional prompt at 50%.
132
+ Required: ``video_id`` and ``prompts`` (list of 2–10 strings).
133
+ Returns an ``analysis_batch``::
134
+
135
+ {object: "analysis_batch", id, video_id, analyses: […], pricing:
136
+ {full_price_prompts, discounted_prompts, discount_pct,
137
+ estimated_total_seconds, estimated_total_cost_usd}}
138
+
139
+ Poll each returned analysis id individually. Pass ``validate_only=True``
140
+ (or use :meth:`validate_batch`) for a cost-estimate dry-run that returns
141
+ an ``analysis_batch_preview`` with per-prompt ``discount_pct`` (0 or 50)
142
+ and estimates — no credits reserved, no jobs created.
143
+ """
144
+ return self._http.post("/v1/analyses/batch", json_body=params)
145
+
146
+ def validate_batch(self, **params: Any) -> dict[str, Any]:
147
+ """Dry-run a batch (POST /v1/analyses/batch with ``validate_only: true``).
148
+
149
+ Parses all prompts and returns an ``analysis_batch_preview`` with
150
+ per-prompt estimates. No credits reserved, no jobs created.
151
+ """
152
+ params["validate_only"] = True
153
+ return self._http.post("/v1/analyses/batch", json_body=params)
154
+
155
+ def retrieve(self, analysis_id: str) -> dict[str, Any]:
156
+ return self._http.get(f"/v1/analyses/{analysis_id}")
157
+
158
+ def list(self, **params: Any) -> dict[str, Any]:
159
+ return self._http.get("/v1/analyses", params=params)
160
+
161
+ def iter(self, **params: Any) -> Iterator[dict[str, Any]]:
162
+ return self._http.paginate("/v1/analyses", params=params)
163
+
164
+ def cancel(self, analysis_id: str) -> dict[str, Any]:
165
+ return self._http.post(f"/v1/analyses/{analysis_id}/cancel")
166
+
167
+ def rerun(self, analysis_id: str) -> dict[str, Any]:
168
+ """POST /v1/analyses/{id}/rerun — free re-run of an eligible failed analysis.
169
+
170
+ Failed analyses carry ``rerun_eligible: bool`` (present ONLY on
171
+ failed status) — true when the failure occurred during a declared
172
+ platform incident or was flagged by ops. Creates a FRESH analysis at
173
+ no charge (same video, prompt/query, model, options; new id; usage
174
+ stays None — never billed). One free re-run per failed analysis;
175
+ ineligible → 409 ``rerun_not_eligible``.
176
+ """
177
+ return self._http.post(f"/v1/analyses/{analysis_id}/rerun")
178
+
179
+ def create_and_wait(
180
+ self,
181
+ *,
182
+ timeout_s: float = 600.0,
183
+ poll_interval_s: float = 2.0,
184
+ **params: Any,
185
+ ) -> dict[str, Any]:
186
+ """Create an analysis and block until a terminal state (design §7.2).
187
+
188
+ Uses ``Prefer: wait`` server-side sync sugar for the first leg
189
+ (cap 120s), then polls with the given interval until ``timeout_s``.
190
+
191
+ On completion, ``result`` carries ``answer`` (yes|no|indeterminate),
192
+ ``confidence``, ``clips``, ``video_duration_s`` (may be None), and —
193
+ when relevant — ``detected_count`` (count-intent queries; 0 = nothing
194
+ found) and ``indeterminate_reason`` (low_confidence |
195
+ nothing_detected | unsupported_query_form | duration_mismatch).
196
+ ``usage`` (terminal only) is ``{billed_seconds, credit_balance_after}``
197
+ — an immutable snapshot of the balance after THIS analysis settled.
198
+ Do not pass ``validate_only`` here — use :meth:`validate_only`.
199
+ """
200
+ prefer_wait = min(int(timeout_s), 120)
201
+ deadline = time.monotonic() + timeout_s
202
+ analysis = self._http.post(
203
+ "/v1/analyses", json_body=params, headers={"Prefer": f"wait={prefer_wait}"}
204
+ )
205
+ while analysis["status"] not in _TERMINAL:
206
+ if time.monotonic() > deadline:
207
+ raise PrimateTimeoutError(
208
+ f"Analysis {analysis['id']} still {analysis['status']} after {timeout_s}s — "
209
+ f"poll client.analyses.retrieve('{analysis['id']}') or use webhooks."
210
+ )
211
+ time.sleep(poll_interval_s)
212
+ analysis = self.retrieve(analysis["id"])
213
+ return analysis
214
+
215
+
216
+ class Streams:
217
+ def __init__(self, http: HttpClient) -> None:
218
+ self._http = http
219
+
220
+ def create(self, **params: Any) -> dict[str, Any]:
221
+ """POST /v1/streams — create a real-time WebRTC analysis stream.
222
+
223
+ Returns ``signaling.url`` + ``ice_servers`` + ``limits``. Signaling
224
+ protocol v1 over the WS (auth: ``?token=`` with a pvct\\_ client token,
225
+ never a secret key): join → ready|queued → offer/answer/ice → live →
226
+ result frames; 5s metering ticks; ``end {reason}``. Mid-stream, send
227
+ ``{"type": "update_prompt", "prompt": "..."}`` (≤2000 chars) to change
228
+ the question without reconnecting — the server confirms with
229
+ ``{"type": "prompt_updated"}`` and subsequent frames echo the new
230
+ prompt.
231
+
232
+ Opt-ins:
233
+
234
+ - ``options={"narrative": True}`` — live narrative. Result-frame
235
+ detections then carry ``narrative_update: {t_s, text}`` whenever the
236
+ engine emits a new sentence (event-driven, absent otherwise), and
237
+ the WS additionally delivers ``status`` events
238
+ (``{"type": "status", "status": "prompt_context" |
239
+ "combined_prompt" | "recalculating", "message": ...}`` — closed
240
+ vocabulary) for ordering/annotating narrative entries. No surcharge.
241
+ - ``recording=True`` — server-side session recording; retrieve the
242
+ playback URL after the stream ends via :meth:`recording`.
243
+ """
244
+ return self._http.post("/v1/streams", json_body=params or {})
245
+
246
+ def retrieve(self, stream_id: str) -> dict[str, Any]:
247
+ return self._http.get(f"/v1/streams/{stream_id}")
248
+
249
+ def list(self, **params: Any) -> dict[str, Any]:
250
+ return self._http.get("/v1/streams", params=params)
251
+
252
+ def iter(self, **params: Any) -> Iterator[dict[str, Any]]:
253
+ return self._http.paginate("/v1/streams", params=params)
254
+
255
+ def end(self, stream_id: str) -> dict[str, Any]:
256
+ """POST /v1/streams/{id}/end (idempotent).
257
+
258
+ The terminal stream resource carries:
259
+
260
+ - ``end_reason``: completed | canceled | insufficient_credits | error |
261
+ timeout | ice_failed | media_timeout. A stream that never went live
262
+ is never "completed"; ice_failed/media_timeout/canceled bill 0.
263
+ - ``results_summary``: ``{result_frames, frames (deprecated alias,
264
+ removed ~2026-08-28 — read result_frames), last_detections}``.
265
+ result_frames counts result EVENTS emitted (sampled, not per-frame).
266
+ - ``failure_diagnostic``: on ice_failed (and some media_timeout cases),
267
+ ``{local_candidates, hint}`` — the server-side ICE candidate summary.
268
+ - ``usage``: ``{billed_seconds, credit_balance_after}`` — live-clock
269
+ seconds; join/negotiation free.
270
+
271
+ While live, the signaling WS sends 5s metering ticks
272
+ ``{elapsed_s, billed_s, session_remaining_s}`` (``balance_s`` is a
273
+ deprecated alias of session_remaining_s, removed ~2026-08-28) and may
274
+ push a ``warning`` with ``code: "no_media_frames"`` when transport
275
+ connected but no decodable frames arrived.
276
+ """
277
+ return self._http.post(f"/v1/streams/{stream_id}/end")
278
+
279
+ def recording(self, stream_id: str) -> dict[str, Any]:
280
+ """GET /v1/streams/{id}/recording — signed session-recording URL.
281
+
282
+ Requires the stream to have been created with ``recording=True`` and
283
+ ended with a stored recording (``stream["recording"]["status"] ==
284
+ "available"``). Returns::
285
+
286
+ {object: "stream_recording", stream_id, url, expires_at,
287
+ content_type: "video/h264", container: "h264-annex-b"}
288
+
289
+ The URL is signed FRESH on every call (1h TTL) — re-fetch for a new
290
+ one. The recording is a raw H.264 Annex-B elementary stream (the
291
+ exact annotated frames the client saw); lossless remux to MP4:
292
+ ``ffmpeg -i recording.h264 -c copy recording.mp4``.
293
+ 404 when recording wasn't enabled; 409 while the stream is active,
294
+ when capture failed, or when nothing was stored.
295
+ """
296
+ return self._http.get(f"/v1/streams/{stream_id}/recording")
297
+
298
+
299
+ class ClientTokens:
300
+ def __init__(self, http: HttpClient) -> None:
301
+ self._http = http
302
+
303
+ def create(self, **params: Any) -> dict[str, Any]:
304
+ """POST /v1/client_tokens — mint an ephemeral browser-safe pvct_ token."""
305
+ return self._http.post("/v1/client_tokens", json_body=params)
306
+
307
+
308
+ class Models:
309
+ def __init__(self, http: HttpClient) -> None:
310
+ self._http = http
311
+
312
+ def list(self) -> dict[str, Any]:
313
+ return self._http.get("/v1/models")
314
+
315
+
316
+ class Usage:
317
+ def __init__(self, http: HttpClient) -> None:
318
+ self._http = http
319
+
320
+ def retrieve(self) -> dict[str, Any]:
321
+ """GET /v1/usage — credit balance + period meters.
322
+
323
+ For the per-analysis transaction ledger, use :class:`Credits`
324
+ (``client.credits.retrieve()``).
325
+ """
326
+ return self._http.get("/v1/usage")
327
+
328
+
329
+ class Credits:
330
+ def __init__(self, http: HttpClient) -> None:
331
+ self._http = http
332
+
333
+ def retrieve(self, *, limit: Optional[int] = None, before: Optional[str] = None) -> dict[str, Any]:
334
+ """GET /v1/credits — balance + paginated transaction ledger.
335
+
336
+ Agent-facing billing truth: check this before submitting work to avoid
337
+ a 402. Returns::
338
+
339
+ {object: "credits", balance_seconds, grant_seconds, used_seconds,
340
+ transactions: [{id, kind, seconds_delta, balance_after_seconds,
341
+ source_type, source_id, created_at}],
342
+ has_more: bool}
343
+
344
+ Each debit carries the analysis/stream id it came from in
345
+ ``source_id`` (``source_type: "analysis"``). ``limit`` is 1–100
346
+ (default 20); ``before`` is a cursor returning transactions older
347
+ than that id.
348
+ """
349
+ params: dict[str, Any] = {}
350
+ if limit is not None:
351
+ params["limit"] = limit
352
+ if before is not None:
353
+ params["before"] = before
354
+ return self._http.get("/v1/credits", params=params or None)
355
+
356
+
357
+ class CreditPricing:
358
+ def __init__(self, http: HttpClient) -> None:
359
+ self._http = http
360
+
361
+ def retrieve(self) -> dict[str, Any]:
362
+ """GET /v1/credit-pricing — current pricing config (public, no auth).
363
+
364
+ Returns ``{price_per_second_cents, signup_grant_seconds,
365
+ card_grant_seconds, auto_refill_threshold_seconds,
366
+ allowed_purchase_cents, custom_purchase_enabled, min_purchase_cents}``.
367
+ Cost of an analysis = ``usage.billed_seconds × price_per_second_cents``.
368
+ """
369
+ return self._http.get("/v1/credit-pricing")
370
+
371
+
372
+ class Errors:
373
+ def __init__(self, http: HttpClient) -> None:
374
+ self._http = http
375
+
376
+ def list(self) -> dict[str, Any]:
377
+ """GET /v1/errors — the machine-readable error registry."""
378
+ return self._http.get("/v1/errors")
379
+
380
+
381
+ class WebhookEndpoints:
382
+ def __init__(self, http: HttpClient) -> None:
383
+ self._http = http
384
+
385
+ def create(self, **params: Any) -> dict[str, Any]:
386
+ """POST /v1/webhook_endpoints — response carries the one-time whsec_ secret."""
387
+ return self._http.post("/v1/webhook_endpoints", json_body=params)
388
+
389
+ def retrieve(self, endpoint_id: str) -> dict[str, Any]:
390
+ return self._http.get(f"/v1/webhook_endpoints/{endpoint_id}")
391
+
392
+ def list(self, **params: Any) -> dict[str, Any]:
393
+ return self._http.get("/v1/webhook_endpoints", params=params)
394
+
395
+ def delete(self, endpoint_id: str) -> dict[str, Any]:
396
+ return self._http.delete(f"/v1/webhook_endpoints/{endpoint_id}")
397
+
398
+ def rotate_secret(self, endpoint_id: str) -> dict[str, Any]:
399
+ return self._http.post(f"/v1/webhook_endpoints/{endpoint_id}/rotate_secret")
400
+
401
+ def enable(self, endpoint_id: str) -> dict[str, Any]:
402
+ return self._http.post(f"/v1/webhook_endpoints/{endpoint_id}/enable")
403
+
404
+ def list_deliveries(self, endpoint_id: str, **params: Any) -> dict[str, Any]:
405
+ return self._http.get(f"/v1/webhook_endpoints/{endpoint_id}/deliveries", params=params)
406
+
407
+ def redeliver(self, endpoint_id: str, delivery_id: str) -> dict[str, Any]:
408
+ return self._http.post(
409
+ f"/v1/webhook_endpoints/{endpoint_id}/deliveries/{delivery_id}/redeliver"
410
+ )
411
+
412
+
413
+ class TestFixtures:
414
+ def __init__(self, http: HttpClient) -> None:
415
+ self._http = http
416
+
417
+ def retrieve(self) -> dict[str, Any]:
418
+ """GET /v1/test-fixture — stable fixture for CI self-verification (§5.5)."""
419
+ return self._http.get("/v1/test-fixture")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "primate-intelligence"
7
- version = "0.1.0"
7
+ version = "0.3.0"
8
8
  description = "Official Python SDK for the Primate Vision API (Primate Intelligence). Video scene understanding: upload, analyze, stream."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -0,0 +1,156 @@
1
+ """PRI-480 parity tests — new resources exercised against RECORDED LIVE API
2
+ responses (captured 2026-07-28 from https://api.primateintelligence.ai with a
3
+ sandbox key), never invented shapes. Offline via httpx MockTransport."""
4
+ from __future__ import annotations
5
+
6
+ import json
7
+
8
+ import httpx
9
+
10
+ from primate_intelligence import Primate
11
+
12
+ # ── Recorded live fixtures (2026-07-28) ──────────────────────────────────────
13
+
14
+ LIVE_CREDITS = {
15
+ "object": "credits",
16
+ "balance_seconds": 6000,
17
+ "grant_seconds": 6000,
18
+ "used_seconds": 0,
19
+ "transactions": [
20
+ {
21
+ "id": "09b1463b-a461-4eba-864b-1a7fd46c6844",
22
+ "kind": "signup_grant",
23
+ "seconds_delta": 6000,
24
+ "balance_after_seconds": 6000,
25
+ "source_type": None,
26
+ "source_id": None,
27
+ "created_at": "2026-07-28T19:59:13.406703+00:00",
28
+ }
29
+ ],
30
+ "has_more": False,
31
+ }
32
+
33
+ LIVE_PRICING = {
34
+ "price_per_second_cents": 1,
35
+ "signup_grant_seconds": 6000,
36
+ "card_grant_seconds": 6000,
37
+ "auto_refill_threshold_seconds": 600,
38
+ "allowed_purchase_cents": [1000, 2500, 5000, 10000],
39
+ "custom_purchase_enabled": True,
40
+ "min_purchase_cents": 1000,
41
+ }
42
+
43
+ LIVE_PREVIEW = {
44
+ "object": "analysis_preview",
45
+ "query": {"query_type": "object", "search_terms": ["forklift"]},
46
+ "parse_mode": "llm",
47
+ "assessable": True,
48
+ "video_duration_s": 22.967,
49
+ "estimated_seconds": 23,
50
+ "estimated_cost_usd": 0.23,
51
+ }
52
+
53
+ LIVE_BATCH_PREVIEW = {
54
+ "object": "analysis_batch_preview",
55
+ "video_id": "video_01KYN50SNEJMXBAY56VP0RVYR4",
56
+ "prompts": [
57
+ {"index": 0, "query": {}, "parse_mode": "llm", "assessable": True,
58
+ "estimated_seconds": None, "estimated_cost_usd": None, "discount_pct": 0},
59
+ {"index": 1, "query": {}, "parse_mode": "llm", "assessable": True,
60
+ "estimated_seconds": None, "estimated_cost_usd": None, "discount_pct": 50},
61
+ ],
62
+ "pricing": {
63
+ "full_price_prompts": 1,
64
+ "discounted_prompts": 1,
65
+ "discount_pct": 50,
66
+ "estimated_total_seconds": None,
67
+ "estimated_total_cost_usd": None,
68
+ },
69
+ }
70
+
71
+
72
+ def _client(handler) -> Primate:
73
+ return Primate(api_key="pv_test_k", transport=httpx.MockTransport(handler))
74
+
75
+
76
+ def test_credits_retrieve_hits_v1_credits():
77
+ seen = {}
78
+
79
+ def handler(request: httpx.Request) -> httpx.Response:
80
+ seen["path"] = request.url.path
81
+ seen["query"] = dict(request.url.params)
82
+ return httpx.Response(200, json=LIVE_CREDITS)
83
+
84
+ out = _client(handler).credits.retrieve()
85
+ assert seen["path"] == "/v1/credits"
86
+ assert seen["query"] == {}
87
+ assert out["object"] == "credits"
88
+ assert out["balance_seconds"] == 6000
89
+ assert out["transactions"][0]["kind"] == "signup_grant"
90
+ assert out["transactions"][0]["source_id"] is None
91
+
92
+
93
+ def test_credits_retrieve_pagination_params():
94
+ seen = {}
95
+
96
+ def handler(request: httpx.Request) -> httpx.Response:
97
+ seen["query"] = dict(request.url.params)
98
+ return httpx.Response(200, json=LIVE_CREDITS)
99
+
100
+ _client(handler).credits.retrieve(limit=5, before="txn_x")
101
+ assert seen["query"] == {"limit": "5", "before": "txn_x"}
102
+
103
+
104
+ def test_credit_pricing_retrieve():
105
+ def handler(request: httpx.Request) -> httpx.Response:
106
+ assert request.url.path == "/v1/credit-pricing"
107
+ return httpx.Response(200, json=LIVE_PRICING)
108
+
109
+ out = _client(handler).credit_pricing.retrieve()
110
+ assert out["price_per_second_cents"] == 1
111
+ assert out["allowed_purchase_cents"] == [1000, 2500, 5000, 10000]
112
+
113
+
114
+ def test_validate_only_sends_flag_and_returns_preview():
115
+ seen = {}
116
+
117
+ def handler(request: httpx.Request) -> httpx.Response:
118
+ seen["body"] = json.loads(request.content)
119
+ return httpx.Response(200, json=LIVE_PREVIEW)
120
+
121
+ out = _client(handler).analyses.validate_only(video_id="video_x", prompt="Is there a forklift?")
122
+ assert seen["body"]["validate_only"] is True
123
+ assert out["object"] == "analysis_preview"
124
+ assert out["assessable"] is True
125
+ assert out["estimated_seconds"] == 23
126
+
127
+
128
+ def test_create_batch_posts_and_auto_idempotency_key():
129
+ seen = {}
130
+
131
+ def handler(request: httpx.Request) -> httpx.Response:
132
+ seen["path"] = request.url.path
133
+ seen["idem"] = request.headers.get("Idempotency-Key")
134
+ seen["body"] = json.loads(request.content)
135
+ return httpx.Response(202, json={"object": "analysis_batch", "id": "ab_x",
136
+ "video_id": "video_x", "analyses": [],
137
+ "pricing": LIVE_BATCH_PREVIEW["pricing"]})
138
+
139
+ out = _client(handler).analyses.create_batch(video_id="video_x", prompts=["a?", "b?"])
140
+ assert seen["path"] == "/v1/analyses/batch"
141
+ assert seen["idem"] and len(seen["idem"]) == 36
142
+ assert "validate_only" not in seen["body"]
143
+ assert out["object"] == "analysis_batch"
144
+
145
+
146
+ def test_validate_batch_returns_live_preview_shape():
147
+ def handler(request: httpx.Request) -> httpx.Response:
148
+ body = json.loads(request.content)
149
+ assert body["validate_only"] is True
150
+ return httpx.Response(200, json=LIVE_BATCH_PREVIEW)
151
+
152
+ out = _client(handler).analyses.validate_batch(video_id="video_x", prompts=["a?", "b?"])
153
+ assert out["object"] == "analysis_batch_preview"
154
+ assert out["prompts"][0]["discount_pct"] == 0
155
+ assert out["prompts"][1]["discount_pct"] == 50
156
+ assert out["pricing"]["discount_pct"] == 50
@@ -1,210 +0,0 @@
1
- """Typed resource namespaces — mirror the /v1 surface 1:1.
2
-
3
- Responses are returned as dicts matching the OpenAPI schemas
4
- (https://api.primateintelligence.ai/v1/openapi.json). Structured so a
5
- Stainless-generated client can replace this module without changing the
6
- public import surface (A6 fallback path, PRI-438).
7
- """
8
- from __future__ import annotations
9
-
10
- import time
11
- from typing import Any, Iterator, Optional
12
-
13
- import httpx
14
-
15
- from ._client import HttpClient
16
- from ._errors import PrimateTimeoutError
17
-
18
- _TERMINAL = {"completed", "failed", "canceled"}
19
-
20
-
21
- class Videos:
22
- def __init__(self, http: HttpClient) -> None:
23
- self._http = http
24
-
25
- def create(self, **params: Any) -> dict[str, Any]:
26
- """POST /v1/videos — presigned-upload create or URL-ingest create."""
27
- return self._http.post("/v1/videos", json_body=params)
28
-
29
- def retrieve(self, video_id: str) -> dict[str, Any]:
30
- return self._http.get(f"/v1/videos/{video_id}")
31
-
32
- def list(self, **params: Any) -> dict[str, Any]:
33
- return self._http.get("/v1/videos", params=params)
34
-
35
- def iter(self, **params: Any) -> Iterator[dict[str, Any]]:
36
- return self._http.paginate("/v1/videos", params=params)
37
-
38
- def delete(self, video_id: str) -> dict[str, Any]:
39
- return self._http.delete(f"/v1/videos/{video_id}")
40
-
41
- def complete(self, video_id: str) -> dict[str, Any]:
42
- """POST /v1/videos/{id}/complete — after the presigned S3 PUT."""
43
- return self._http.post(f"/v1/videos/{video_id}/complete")
44
-
45
- def upload(
46
- self,
47
- file: bytes,
48
- *,
49
- filename: str,
50
- content_type: str = "video/mp4",
51
- metadata: Optional[dict[str, str]] = None,
52
- ) -> dict[str, Any]:
53
- """Create + presigned S3 PUT + complete, in one call."""
54
- video = self.create(
55
- filename=filename,
56
- content_type=content_type,
57
- size_bytes=len(file),
58
- **({"metadata": metadata} if metadata else {}),
59
- )
60
- upload = video.get("upload")
61
- if not upload:
62
- raise RuntimeError("API did not return presigned upload details")
63
- res = httpx.put(upload["url"], content=file, headers=upload.get("headers", {}), timeout=600)
64
- res.raise_for_status()
65
- return self.complete(video["id"])
66
-
67
-
68
- class Analyses:
69
- def __init__(self, http: HttpClient) -> None:
70
- self._http = http
71
-
72
- def create(self, **params: Any) -> dict[str, Any]:
73
- """POST /v1/analyses."""
74
- return self._http.post("/v1/analyses", json_body=params)
75
-
76
- def retrieve(self, analysis_id: str) -> dict[str, Any]:
77
- return self._http.get(f"/v1/analyses/{analysis_id}")
78
-
79
- def list(self, **params: Any) -> dict[str, Any]:
80
- return self._http.get("/v1/analyses", params=params)
81
-
82
- def iter(self, **params: Any) -> Iterator[dict[str, Any]]:
83
- return self._http.paginate("/v1/analyses", params=params)
84
-
85
- def cancel(self, analysis_id: str) -> dict[str, Any]:
86
- return self._http.post(f"/v1/analyses/{analysis_id}/cancel")
87
-
88
- def create_and_wait(
89
- self,
90
- *,
91
- timeout_s: float = 600.0,
92
- poll_interval_s: float = 2.0,
93
- **params: Any,
94
- ) -> dict[str, Any]:
95
- """Create an analysis and block until a terminal state (design §7.2).
96
-
97
- Uses ``Prefer: wait`` server-side sync sugar for the first leg
98
- (cap 120s), then polls with the given interval until ``timeout_s``.
99
- """
100
- prefer_wait = min(int(timeout_s), 120)
101
- deadline = time.monotonic() + timeout_s
102
- analysis = self._http.post(
103
- "/v1/analyses", json_body=params, headers={"Prefer": f"wait={prefer_wait}"}
104
- )
105
- while analysis["status"] not in _TERMINAL:
106
- if time.monotonic() > deadline:
107
- raise PrimateTimeoutError(
108
- f"Analysis {analysis['id']} still {analysis['status']} after {timeout_s}s — "
109
- f"poll client.analyses.retrieve('{analysis['id']}') or use webhooks."
110
- )
111
- time.sleep(poll_interval_s)
112
- analysis = self.retrieve(analysis["id"])
113
- return analysis
114
-
115
-
116
- class Streams:
117
- def __init__(self, http: HttpClient) -> None:
118
- self._http = http
119
-
120
- def create(self, **params: Any) -> dict[str, Any]:
121
- return self._http.post("/v1/streams", json_body=params or {})
122
-
123
- def retrieve(self, stream_id: str) -> dict[str, Any]:
124
- return self._http.get(f"/v1/streams/{stream_id}")
125
-
126
- def list(self, **params: Any) -> dict[str, Any]:
127
- return self._http.get("/v1/streams", params=params)
128
-
129
- def iter(self, **params: Any) -> Iterator[dict[str, Any]]:
130
- return self._http.paginate("/v1/streams", params=params)
131
-
132
- def end(self, stream_id: str) -> dict[str, Any]:
133
- """POST /v1/streams/{id}/end (idempotent)."""
134
- return self._http.post(f"/v1/streams/{stream_id}/end")
135
-
136
-
137
- class ClientTokens:
138
- def __init__(self, http: HttpClient) -> None:
139
- self._http = http
140
-
141
- def create(self, **params: Any) -> dict[str, Any]:
142
- """POST /v1/client_tokens — mint an ephemeral browser-safe pvct_ token."""
143
- return self._http.post("/v1/client_tokens", json_body=params)
144
-
145
-
146
- class Models:
147
- def __init__(self, http: HttpClient) -> None:
148
- self._http = http
149
-
150
- def list(self) -> dict[str, Any]:
151
- return self._http.get("/v1/models")
152
-
153
-
154
- class Usage:
155
- def __init__(self, http: HttpClient) -> None:
156
- self._http = http
157
-
158
- def retrieve(self) -> dict[str, Any]:
159
- """GET /v1/usage — credit balance + period meters."""
160
- return self._http.get("/v1/usage")
161
-
162
-
163
- class Errors:
164
- def __init__(self, http: HttpClient) -> None:
165
- self._http = http
166
-
167
- def list(self) -> dict[str, Any]:
168
- """GET /v1/errors — the machine-readable error registry."""
169
- return self._http.get("/v1/errors")
170
-
171
-
172
- class WebhookEndpoints:
173
- def __init__(self, http: HttpClient) -> None:
174
- self._http = http
175
-
176
- def create(self, **params: Any) -> dict[str, Any]:
177
- """POST /v1/webhook_endpoints — response carries the one-time whsec_ secret."""
178
- return self._http.post("/v1/webhook_endpoints", json_body=params)
179
-
180
- def retrieve(self, endpoint_id: str) -> dict[str, Any]:
181
- return self._http.get(f"/v1/webhook_endpoints/{endpoint_id}")
182
-
183
- def list(self, **params: Any) -> dict[str, Any]:
184
- return self._http.get("/v1/webhook_endpoints", params=params)
185
-
186
- def delete(self, endpoint_id: str) -> dict[str, Any]:
187
- return self._http.delete(f"/v1/webhook_endpoints/{endpoint_id}")
188
-
189
- def rotate_secret(self, endpoint_id: str) -> dict[str, Any]:
190
- return self._http.post(f"/v1/webhook_endpoints/{endpoint_id}/rotate_secret")
191
-
192
- def enable(self, endpoint_id: str) -> dict[str, Any]:
193
- return self._http.post(f"/v1/webhook_endpoints/{endpoint_id}/enable")
194
-
195
- def list_deliveries(self, endpoint_id: str, **params: Any) -> dict[str, Any]:
196
- return self._http.get(f"/v1/webhook_endpoints/{endpoint_id}/deliveries", params=params)
197
-
198
- def redeliver(self, endpoint_id: str, delivery_id: str) -> dict[str, Any]:
199
- return self._http.post(
200
- f"/v1/webhook_endpoints/{endpoint_id}/deliveries/{delivery_id}/redeliver"
201
- )
202
-
203
-
204
- class TestFixtures:
205
- def __init__(self, http: HttpClient) -> None:
206
- self._http = http
207
-
208
- def retrieve(self) -> dict[str, Any]:
209
- """GET /v1/test-fixture — stable fixture for CI self-verification (§5.5)."""
210
- return self._http.get("/v1/test-fixture")