primate-intelligence 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.
@@ -0,0 +1,82 @@
1
+ """primate-intelligence — official Python SDK for the Primate Vision API.
2
+
3
+ from primate_intelligence import Primate
4
+
5
+ client = Primate() # reads PRIMATE_API_KEY
6
+ video = client.videos.create(url="https://example.com/clip.mp4")
7
+ analysis = client.analyses.create_and_wait(
8
+ video_id=video["id"], prompt="Is there a person in this video?"
9
+ )
10
+ print(analysis["result"]["answer"])
11
+
12
+ Docs: https://primateintelligence.ai/docs
13
+ Spec: https://api.primateintelligence.ai/v1/openapi.json
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from typing import Optional
18
+
19
+ import httpx
20
+
21
+ from ._client import HttpClient
22
+ from ._errors import PrimateError, PrimateTimeoutError, registry_flags
23
+ from ._resources import (
24
+ Analyses,
25
+ ClientTokens,
26
+ Errors,
27
+ Models,
28
+ Streams,
29
+ TestFixtures,
30
+ Usage,
31
+ Videos,
32
+ WebhookEndpoints,
33
+ )
34
+ from .webhooks import verify_webhook, verify_webhook_request
35
+
36
+ __version__ = "0.1.0"
37
+ __all__ = [
38
+ "Primate",
39
+ "PrimateError",
40
+ "PrimateTimeoutError",
41
+ "registry_flags",
42
+ "verify_webhook",
43
+ "verify_webhook_request",
44
+ ]
45
+
46
+
47
+ class Primate:
48
+ """Entry point. All options optional — PRIMATE_API_KEY env var is the default auth."""
49
+
50
+ def __init__(
51
+ self,
52
+ api_key: Optional[str] = None,
53
+ base_url: Optional[str] = None,
54
+ max_retries: int = 3,
55
+ timeout_s: float = 60.0,
56
+ transport: Optional[httpx.BaseTransport] = None,
57
+ ) -> None:
58
+ self.http = HttpClient(
59
+ api_key=api_key,
60
+ base_url=base_url,
61
+ max_retries=max_retries,
62
+ timeout_s=timeout_s,
63
+ transport=transport,
64
+ )
65
+ self.videos = Videos(self.http)
66
+ self.analyses = Analyses(self.http)
67
+ self.streams = Streams(self.http)
68
+ self.client_tokens = ClientTokens(self.http)
69
+ self.models = Models(self.http)
70
+ self.usage = Usage(self.http)
71
+ self.errors = Errors(self.http)
72
+ self.webhook_endpoints = WebhookEndpoints(self.http)
73
+ self.test_fixtures = TestFixtures(self.http)
74
+
75
+ def close(self) -> None:
76
+ self.http.close()
77
+
78
+ def __enter__(self) -> "Primate":
79
+ return self
80
+
81
+ def __exit__(self, *exc: object) -> None:
82
+ self.close()
@@ -0,0 +1,157 @@
1
+ """Core HTTP client for the Primate Vision API.
2
+
3
+ - Auth: PRIMATE_API_KEY env var by default (design §5.3).
4
+ - Retry: registry ``retryable`` flags + ``Retry-After`` (design §15/§16.2).
5
+ - Idempotency: auto UUIDv4 ``Idempotency-Key`` on create calls (§16.1).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import random
11
+ import time
12
+ import uuid
13
+ from typing import Any, Iterator, Optional
14
+
15
+ import httpx
16
+
17
+ from ._errors import PrimateError
18
+
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"
22
+
23
+
24
+ class HttpClient:
25
+ def __init__(
26
+ self,
27
+ api_key: Optional[str] = None,
28
+ base_url: Optional[str] = None,
29
+ max_retries: int = 3,
30
+ timeout_s: float = 60.0,
31
+ transport: Optional[httpx.BaseTransport] = None,
32
+ ) -> None:
33
+ key = api_key or os.environ.get("PRIMATE_API_KEY")
34
+ if not key:
35
+ raise ValueError(
36
+ "Missing API key. Set the PRIMATE_API_KEY environment variable or pass api_key= — "
37
+ "get a free test key: curl -X POST https://api.primateintelligence.ai/v1/sandbox"
38
+ )
39
+ self._api_key = key
40
+ self.base_url = (base_url or os.environ.get("PRIMATE_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
41
+ self.max_retries = max_retries
42
+ self._http = httpx.Client(
43
+ timeout=timeout_s,
44
+ transport=transport,
45
+ headers={"Authorization": f"Bearer {key}", "User-Agent": _USER_AGENT},
46
+ )
47
+
48
+ def close(self) -> None:
49
+ self._http.close()
50
+
51
+ def __enter__(self) -> "HttpClient":
52
+ return self
53
+
54
+ def __exit__(self, *exc: object) -> None:
55
+ self.close()
56
+
57
+ # ── Core request with registry-driven retry ──────────────────────────────
58
+
59
+ def request(
60
+ self,
61
+ method: str,
62
+ path: str,
63
+ *,
64
+ json_body: Optional[dict[str, Any]] = None,
65
+ params: Optional[dict[str, Any]] = None,
66
+ headers: Optional[dict[str, str]] = None,
67
+ idempotency_key: Optional[str] = None,
68
+ max_retries: Optional[int] = None,
69
+ ) -> Any:
70
+ url = self.base_url + path
71
+ hdrs = dict(headers or {})
72
+
73
+ # §16.1 — auto idempotency keys on create calls.
74
+ if method == "POST" and path in _IDEMPOTENT_CREATE_PATHS:
75
+ hdrs["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
76
+ elif idempotency_key:
77
+ hdrs["Idempotency-Key"] = idempotency_key
78
+
79
+ retries = self.max_retries if max_retries is None else max_retries
80
+ last_exc: Optional[Exception] = None
81
+
82
+ for attempt in range(retries + 1):
83
+ try:
84
+ res = self._http.request(method, url, json=json_body, params=params, headers=hdrs)
85
+ except httpx.TransportError as exc: # network-level: retry
86
+ last_exc = exc
87
+ if attempt < retries:
88
+ time.sleep(_backoff_s(None, attempt))
89
+ continue
90
+ raise
91
+
92
+ if res.is_success:
93
+ if res.status_code == 204 or not res.content:
94
+ return None
95
+ return res.json()
96
+
97
+ err = _to_primate_error(res)
98
+ if err.retryable and attempt < retries:
99
+ time.sleep(_backoff_s(res, attempt))
100
+ last_exc = err
101
+ continue
102
+ raise err
103
+
104
+ raise last_exc # pragma: no cover — loop always returns or raises
105
+
106
+ def get(self, path: str, params: Optional[dict[str, Any]] = None, **kw: Any) -> Any:
107
+ return self.request("GET", path, params=params, **kw)
108
+
109
+ def post(self, path: str, json_body: Optional[dict[str, Any]] = None, **kw: Any) -> Any:
110
+ return self.request("POST", path, json_body=json_body, **kw)
111
+
112
+ def delete(self, path: str, **kw: Any) -> Any:
113
+ return self.request("DELETE", path, **kw)
114
+
115
+ # ── Pagination (§13.4: starting_after + has_more) ─────────────────────────
116
+
117
+ def paginate(self, path: str, params: Optional[dict[str, Any]] = None) -> Iterator[dict[str, Any]]:
118
+ params = dict(params or {})
119
+ while True:
120
+ page = self.get(path, params=params)
121
+ data = page.get("data", [])
122
+ yield from data
123
+ if not page.get("has_more") or not data:
124
+ return
125
+ params["starting_after"] = data[-1]["id"]
126
+
127
+
128
+ def _to_primate_error(res: httpx.Response) -> PrimateError:
129
+ try:
130
+ e = res.json().get("error", {})
131
+ except Exception:
132
+ e = {}
133
+ return PrimateError(
134
+ code=e.get("code", "internal_error"),
135
+ message=e.get("message", f"HTTP {res.status_code}"),
136
+ status=res.status_code,
137
+ param=e.get("param"),
138
+ docs_url=e.get("docs_url"),
139
+ request_id=e.get("request_id"),
140
+ errors=e.get("errors"),
141
+ details=e.get("details"),
142
+ )
143
+
144
+
145
+ def _backoff_s(res: Optional[httpx.Response], attempt: int) -> float:
146
+ """Exponential backoff with full jitter; honors Retry-After when present."""
147
+ if res is not None:
148
+ retry_after = res.headers.get("Retry-After")
149
+ if retry_after:
150
+ try:
151
+ s = int(retry_after)
152
+ if s > 0:
153
+ return float(s)
154
+ except ValueError:
155
+ pass
156
+ base = min(1.0 * (2**attempt), 8.0)
157
+ return base * (0.5 + random.random())
@@ -0,0 +1,285 @@
1
+ {
2
+ "_generated": "scripts/generate-sdk-artifacts.ts — do not hand-edit",
3
+ "codes": {
4
+ "invalid_api_key": {
5
+ "kind": "http",
6
+ "status": 401,
7
+ "retryable": false,
8
+ "idem": false,
9
+ "docs_url": "https://primateintelligence.ai/docs/errors#invalid_api_key"
10
+ },
11
+ "key_revoked": {
12
+ "kind": "http",
13
+ "status": 401,
14
+ "retryable": false,
15
+ "idem": false,
16
+ "docs_url": "https://primateintelligence.ai/docs/errors#key_revoked"
17
+ },
18
+ "key_expired": {
19
+ "kind": "http",
20
+ "status": 401,
21
+ "retryable": false,
22
+ "idem": false,
23
+ "docs_url": "https://primateintelligence.ai/docs/errors#key_expired"
24
+ },
25
+ "token_expired": {
26
+ "kind": "http",
27
+ "status": 401,
28
+ "retryable": false,
29
+ "idem": false,
30
+ "docs_url": "https://primateintelligence.ai/docs/errors#token_expired"
31
+ },
32
+ "insufficient_scope": {
33
+ "kind": "http",
34
+ "status": 403,
35
+ "retryable": false,
36
+ "idem": false,
37
+ "docs_url": "https://primateintelligence.ai/docs/errors#insufficient_scope"
38
+ },
39
+ "account_restricted": {
40
+ "kind": "http",
41
+ "status": 403,
42
+ "retryable": false,
43
+ "idem": false,
44
+ "docs_url": "https://primateintelligence.ai/docs/errors#account_restricted"
45
+ },
46
+ "test_mode_only": {
47
+ "kind": "http",
48
+ "status": 403,
49
+ "retryable": false,
50
+ "idem": false,
51
+ "docs_url": "https://primateintelligence.ai/docs/errors#test_mode_only"
52
+ },
53
+ "validation_failed": {
54
+ "kind": "http",
55
+ "status": 400,
56
+ "retryable": false,
57
+ "idem": false,
58
+ "docs_url": "https://primateintelligence.ai/docs/errors#validation_failed"
59
+ },
60
+ "unsupported_media_type": {
61
+ "kind": "http",
62
+ "status": 415,
63
+ "retryable": false,
64
+ "idem": false,
65
+ "docs_url": "https://primateintelligence.ai/docs/errors#unsupported_media_type"
66
+ },
67
+ "payload_too_large": {
68
+ "kind": "http",
69
+ "status": 413,
70
+ "retryable": false,
71
+ "idem": false,
72
+ "docs_url": "https://primateintelligence.ai/docs/errors#payload_too_large"
73
+ },
74
+ "resource_not_found": {
75
+ "kind": "http",
76
+ "status": 404,
77
+ "retryable": false,
78
+ "idem": false,
79
+ "docs_url": "https://primateintelligence.ai/docs/errors#resource_not_found"
80
+ },
81
+ "resource_conflict": {
82
+ "kind": "http",
83
+ "status": 409,
84
+ "retryable": false,
85
+ "idem": false,
86
+ "docs_url": "https://primateintelligence.ai/docs/errors#resource_conflict"
87
+ },
88
+ "idempotency_key_reused": {
89
+ "kind": "http",
90
+ "status": 409,
91
+ "retryable": false,
92
+ "idem": false,
93
+ "docs_url": "https://primateintelligence.ai/docs/errors#idempotency_key_reused"
94
+ },
95
+ "idempotency_unavailable": {
96
+ "kind": "http",
97
+ "status": 409,
98
+ "retryable": true,
99
+ "idem": true,
100
+ "docs_url": "https://primateintelligence.ai/docs/errors#idempotency_unavailable"
101
+ },
102
+ "stream_already_active": {
103
+ "kind": "http",
104
+ "status": 409,
105
+ "retryable": false,
106
+ "idem": false,
107
+ "docs_url": "https://primateintelligence.ai/docs/errors#stream_already_active"
108
+ },
109
+ "upload_incomplete": {
110
+ "kind": "http",
111
+ "status": 400,
112
+ "retryable": false,
113
+ "idem": false,
114
+ "docs_url": "https://primateintelligence.ai/docs/errors#upload_incomplete"
115
+ },
116
+ "video_unreadable": {
117
+ "kind": "http",
118
+ "status": 400,
119
+ "retryable": false,
120
+ "idem": false,
121
+ "docs_url": "https://primateintelligence.ai/docs/errors#video_unreadable"
122
+ },
123
+ "video_too_large": {
124
+ "kind": "http",
125
+ "status": 400,
126
+ "retryable": false,
127
+ "idem": false,
128
+ "docs_url": "https://primateintelligence.ai/docs/errors#video_too_large"
129
+ },
130
+ "video_too_long": {
131
+ "kind": "http",
132
+ "status": 400,
133
+ "retryable": false,
134
+ "idem": false,
135
+ "docs_url": "https://primateintelligence.ai/docs/errors#video_too_long"
136
+ },
137
+ "url_fetch_failed": {
138
+ "kind": "http",
139
+ "status": 400,
140
+ "retryable": true,
141
+ "idem": false,
142
+ "docs_url": "https://primateintelligence.ai/docs/errors#url_fetch_failed"
143
+ },
144
+ "url_forbidden": {
145
+ "kind": "http",
146
+ "status": 400,
147
+ "retryable": false,
148
+ "idem": false,
149
+ "docs_url": "https://primateintelligence.ai/docs/errors#url_forbidden"
150
+ },
151
+ "prompt_empty": {
152
+ "kind": "http",
153
+ "status": 400,
154
+ "retryable": false,
155
+ "idem": false,
156
+ "docs_url": "https://primateintelligence.ai/docs/errors#prompt_empty"
157
+ },
158
+ "prompt_too_long": {
159
+ "kind": "http",
160
+ "status": 400,
161
+ "retryable": false,
162
+ "idem": false,
163
+ "docs_url": "https://primateintelligence.ai/docs/errors#prompt_too_long"
164
+ },
165
+ "query_invalid": {
166
+ "kind": "http",
167
+ "status": 400,
168
+ "retryable": false,
169
+ "idem": false,
170
+ "docs_url": "https://primateintelligence.ai/docs/errors#query_invalid"
171
+ },
172
+ "parse_failed": {
173
+ "kind": "http",
174
+ "status": 422,
175
+ "retryable": true,
176
+ "idem": false,
177
+ "docs_url": "https://primateintelligence.ai/docs/errors#parse_failed"
178
+ },
179
+ "model_not_found": {
180
+ "kind": "http",
181
+ "status": 400,
182
+ "retryable": false,
183
+ "idem": false,
184
+ "docs_url": "https://primateintelligence.ai/docs/errors#model_not_found"
185
+ },
186
+ "model_deprecated": {
187
+ "kind": "http",
188
+ "status": 400,
189
+ "retryable": false,
190
+ "idem": false,
191
+ "docs_url": "https://primateintelligence.ai/docs/errors#model_deprecated"
192
+ },
193
+ "analysis_not_cancelable": {
194
+ "kind": "http",
195
+ "status": 409,
196
+ "retryable": false,
197
+ "idem": false,
198
+ "docs_url": "https://primateintelligence.ai/docs/errors#analysis_not_cancelable"
199
+ },
200
+ "rate_limit_exceeded": {
201
+ "kind": "http",
202
+ "status": 429,
203
+ "retryable": true,
204
+ "idem": true,
205
+ "docs_url": "https://primateintelligence.ai/docs/errors#rate_limit_exceeded"
206
+ },
207
+ "concurrency_limit_exceeded": {
208
+ "kind": "http",
209
+ "status": 429,
210
+ "retryable": true,
211
+ "idem": true,
212
+ "docs_url": "https://primateintelligence.ai/docs/errors#concurrency_limit_exceeded"
213
+ },
214
+ "quota_exceeded": {
215
+ "kind": "http",
216
+ "status": 429,
217
+ "retryable": false,
218
+ "idem": false,
219
+ "docs_url": "https://primateintelligence.ai/docs/errors#quota_exceeded"
220
+ },
221
+ "insufficient_credits": {
222
+ "kind": "http",
223
+ "status": 402,
224
+ "retryable": false,
225
+ "idem": false,
226
+ "docs_url": "https://primateintelligence.ai/docs/errors#insufficient_credits"
227
+ },
228
+ "capacity_exhausted": {
229
+ "kind": "http",
230
+ "status": 503,
231
+ "retryable": true,
232
+ "idem": true,
233
+ "docs_url": "https://primateintelligence.ai/docs/errors#capacity_exhausted"
234
+ },
235
+ "sandbox_limit_exceeded": {
236
+ "kind": "http",
237
+ "status": 429,
238
+ "retryable": true,
239
+ "idem": true,
240
+ "docs_url": "https://primateintelligence.ai/docs/errors#sandbox_limit_exceeded"
241
+ },
242
+ "inference_unavailable": {
243
+ "kind": "http",
244
+ "status": 503,
245
+ "retryable": true,
246
+ "idem": true,
247
+ "docs_url": "https://primateintelligence.ai/docs/errors#inference_unavailable"
248
+ },
249
+ "db_unavailable": {
250
+ "kind": "http",
251
+ "status": 503,
252
+ "retryable": true,
253
+ "idem": true,
254
+ "docs_url": "https://primateintelligence.ai/docs/errors#db_unavailable"
255
+ },
256
+ "internal_error": {
257
+ "kind": "http",
258
+ "status": 500,
259
+ "retryable": true,
260
+ "idem": true,
261
+ "docs_url": "https://primateintelligence.ai/docs/errors#internal_error"
262
+ },
263
+ "webhook_endpoint_limit": {
264
+ "kind": "http",
265
+ "status": 400,
266
+ "retryable": false,
267
+ "idem": false,
268
+ "docs_url": "https://primateintelligence.ai/docs/errors#webhook_endpoint_limit"
269
+ },
270
+ "inference_error": {
271
+ "kind": "resource",
272
+ "status": 0,
273
+ "retryable": false,
274
+ "idem": false,
275
+ "docs_url": "https://primateintelligence.ai/docs/errors#inference_error"
276
+ },
277
+ "stuck_timeout": {
278
+ "kind": "resource",
279
+ "status": 0,
280
+ "retryable": false,
281
+ "idem": false,
282
+ "docs_url": "https://primateintelligence.ai/docs/errors#stuck_timeout"
283
+ }
284
+ }
285
+ }
@@ -0,0 +1,62 @@
1
+ """Error types + registry-driven retry flags (design §15).
2
+
3
+ The registry JSON is generated from the API's single source of truth
4
+ (scripts/generate-sdk-artifacts.ts) — never hand-edit it.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from importlib import resources
10
+ from typing import Any, Optional
11
+
12
+
13
+ def _load_registry() -> dict[str, dict[str, Any]]:
14
+ with resources.files(__package__).joinpath("_error_registry.json").open() as f:
15
+ return json.load(f)["codes"]
16
+
17
+
18
+ _REGISTRY: dict[str, dict[str, Any]] = _load_registry()
19
+
20
+
21
+ def registry_flags(code: str) -> Optional[dict[str, Any]]:
22
+ """Return {kind, status, retryable, idem, docs_url} for an error code."""
23
+ return _REGISTRY.get(code)
24
+
25
+
26
+ class PrimateError(Exception):
27
+ """Raised for every non-2xx API response.
28
+
29
+ Attributes mirror the error envelope (design §4.4):
30
+ code, status, message, param, docs_url, request_id, errors, details.
31
+ ``retryable`` derives from the error registry.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ code: str,
37
+ message: str,
38
+ status: int,
39
+ *,
40
+ param: Optional[str] = None,
41
+ docs_url: Optional[str] = None,
42
+ request_id: Optional[str] = None,
43
+ errors: Optional[list[dict[str, str]]] = None,
44
+ details: Optional[dict[str, Any]] = None,
45
+ ) -> None:
46
+ suffix = f" (request_id: {request_id})" if request_id else ""
47
+ super().__init__(f"{code}: {message}{suffix}")
48
+ self.code = code
49
+ self.message = message
50
+ self.status = status
51
+ self.param = param
52
+ self.docs_url = docs_url
53
+ self.request_id = request_id
54
+ self.errors = errors
55
+ self.details = details
56
+ flags = registry_flags(code)
57
+ self.retryable: bool = bool(flags["retryable"]) if flags else False
58
+ self.idem: bool = bool(flags["idem"]) if flags else False
59
+
60
+
61
+ class PrimateTimeoutError(Exception):
62
+ """Raised by create_and_wait when the wait budget is exhausted."""
@@ -0,0 +1,210 @@
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")
@@ -0,0 +1,72 @@
1
+ """Standard Webhooks signature verification (design §16.3).
2
+
3
+ Signed content: ``{id}.{timestamp}.{payload}`` where payload is the RAW
4
+ request body. HMAC key = base64-decoded portion of the whsec_ secret.
5
+ Rejects timestamp skew > 5 minutes; constant-time compare; accepts any
6
+ matching signature during the 24h dual-secret rotation overlap.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import hashlib
12
+ import hmac
13
+ import time
14
+ from typing import Mapping, Optional, Union
15
+
16
+ TOLERANCE_S = 300
17
+
18
+
19
+ def verify_webhook(
20
+ *,
21
+ secret: str,
22
+ id: str,
23
+ timestamp: str,
24
+ signature: str,
25
+ payload: Union[str, bytes],
26
+ now_s: Optional[int] = None,
27
+ ) -> bool:
28
+ """Return True when the delivery is authentic and inside the replay window."""
29
+ now = now_s if now_s is not None else int(time.time())
30
+ try:
31
+ ts = int(timestamp)
32
+ except (TypeError, ValueError):
33
+ return False
34
+ if abs(now - ts) > TOLERANCE_S:
35
+ return False
36
+
37
+ key = base64.b64decode(secret.removeprefix("whsec_"))
38
+ body = payload.encode() if isinstance(payload, str) else payload
39
+ expected = hmac.new(key, f"{id}.{timestamp}.".encode() + body, hashlib.sha256).digest()
40
+
41
+ for part in signature.split(" "):
42
+ version, _, sig = part.partition(",")
43
+ if version != "v1" or not sig:
44
+ continue
45
+ try:
46
+ candidate = base64.b64decode(sig)
47
+ except Exception:
48
+ continue
49
+ if hmac.compare_digest(candidate, expected):
50
+ return True
51
+ return False
52
+
53
+
54
+ def verify_webhook_request(
55
+ *,
56
+ secret: str,
57
+ headers: Mapping[str, str],
58
+ raw_body: Union[str, bytes],
59
+ now_s: Optional[int] = None,
60
+ ) -> None:
61
+ """Verify from a headers mapping + raw body. Raises ValueError on failure."""
62
+ lower = {k.lower(): v for k, v in headers.items()}
63
+ ok = verify_webhook(
64
+ secret=secret,
65
+ id=lower.get("webhook-id", ""),
66
+ timestamp=lower.get("webhook-timestamp", ""),
67
+ signature=lower.get("webhook-signature", ""),
68
+ payload=raw_body,
69
+ now_s=now_s,
70
+ )
71
+ if not ok:
72
+ raise ValueError("Webhook signature verification failed")
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: primate-intelligence
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Primate Vision API (Primate Intelligence). Video scene understanding: upload, analyze, stream.
5
+ Project-URL: Homepage, https://primateintelligence.ai/docs
6
+ Project-URL: Repository, https://github.com/Primate-Intelligence/primate-intelligence-api
7
+ Author: Primate Intelligence
8
+ License: MIT
9
+ Keywords: ai,primate-intelligence,video,video-understanding,vision
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: httpx<1,>=0.25
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=7; extra == 'dev'
20
+ Requires-Dist: respx>=0.21; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # primate-intelligence
24
+
25
+ Official Python SDK for the [Primate Vision API](https://primateintelligence.ai/docs) by Primate Intelligence — video scene understanding: upload a video, ask a question, get a deterministic answer with confidence and clip timestamps.
26
+
27
+ > **Status:** built in-repo from the OpenAPI spec (`GET /v1/openapi.json`). Not yet published to PyPI — install from the repo until the first public release.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install primate-intelligence
33
+ ```
34
+
35
+ Requires Python 3.10+.
36
+
37
+ ## Quickstart
38
+
39
+ ```python
40
+ from primate_intelligence import Primate
41
+
42
+ client = Primate() # reads PRIMATE_API_KEY from the environment
43
+
44
+ video = client.videos.create(url="https://example.com/clip.mp4")
45
+ analysis = client.analyses.create_and_wait(
46
+ video_id=video["id"],
47
+ prompt="Is there a person in this video?",
48
+ )
49
+ print(analysis["result"]["answer"], analysis["result"]["confidence"])
50
+ ```
51
+
52
+ Get a free test key with no signup:
53
+
54
+ ```bash
55
+ curl -X POST https://api.primateintelligence.ai/v1/sandbox
56
+ ```
57
+
58
+ ## Features
59
+
60
+ - **Typed resource namespaces** for the full `/v1` surface: `videos`, `analyses`, `streams`, `client_tokens`, `models`, `usage`, `errors`, `webhook_endpoints`.
61
+ - **Automatic retries** driven by the [error registry](https://primateintelligence.ai/docs/errors) `retryable` flags + `Retry-After`.
62
+ - **Auto idempotency keys** (UUIDv4) on all create calls.
63
+ - **`create_and_wait()`** — create an analysis and block until a terminal state (uses `Prefer: wait` server-side, then polls).
64
+ - **`videos.upload()`** — create + presigned S3 PUT + complete, in one call.
65
+ - **Pagination iterators** — `for video in client.videos.iter(): …`.
66
+ - **Webhook verification** — `verify_webhook()` implements [Standard Webhooks](https://www.standardwebhooks.com) with constant-time compare + replay-window checks.
67
+
68
+ ## Error handling
69
+
70
+ ```python
71
+ from primate_intelligence import Primate, PrimateError
72
+
73
+ client = Primate()
74
+ try:
75
+ client.analyses.create(video_id="video_x", prompt="hi")
76
+ except PrimateError as err:
77
+ print(err.code, err.status, err.request_id, err.docs_url)
78
+ if err.code == "insufficient_credits":
79
+ usage = client.usage.retrieve()
80
+ # → prompt the account owner to top up or enable auto-refill
81
+ ```
82
+
83
+ Every error carries `code`, `status`, `request_id`, and a `docs_url` anchor into the error registry. Retryable codes are retried automatically before the exception reaches you.
84
+
85
+ ## Webhooks
86
+
87
+ ```python
88
+ from primate_intelligence import verify_webhook_request
89
+
90
+ @app.post("/webhooks/primate")
91
+ async def receive(request: Request):
92
+ raw = await request.body()
93
+ verify_webhook_request(
94
+ secret=os.environ["PRIMATE_WEBHOOK_SECRET"], # whsec_…
95
+ headers=dict(request.headers),
96
+ raw_body=raw,
97
+ ) # raises ValueError on bad signature / stale timestamp
98
+ # dedupe on the webhook-id header — delivery is at-least-once
99
+ return Response(status_code=204)
100
+ ```
101
+
102
+ ## Configuration
103
+
104
+ | Kwarg | Env var | Default |
105
+ |---|---|---|
106
+ | `api_key` | `PRIMATE_API_KEY` | — (required) |
107
+ | `base_url` | `PRIMATE_BASE_URL` | `https://api.primateintelligence.ai` |
108
+ | `max_retries` | — | `3` |
109
+ | `timeout_s` | — | `60.0` |
@@ -0,0 +1,9 @@
1
+ primate_intelligence/__init__.py,sha256=O7R0Mou4yc4DHOvcvHeYHON2tZHgq90Is9Zu7sjsrs0,2242
2
+ primate_intelligence/_client.py,sha256=vWtAq0u7AnvlgpewiFNVyjgd8mG9c6A66u3N10km6Jg,5558
3
+ primate_intelligence/_error_registry.json,sha256=U5PGV5Q49MMnOVrhx1uxvqVUM4HJDZYm8VkQWOLP0jA,8264
4
+ primate_intelligence/_errors.py,sha256=StjckxkOQzds1Y3N7CT832pGMi6WB5MHps3bljK5AyE,1980
5
+ primate_intelligence/_resources.py,sha256=9H7JPJnylFHCklJehiklAS8ojxMfiybo5M6TiPa2bxc,7520
6
+ primate_intelligence/webhooks.py,sha256=vde5AEwJtmkWgd41b7YnvLPIwsEAo_UepheKbt51jXU,2158
7
+ primate_intelligence-0.1.0.dist-info/METADATA,sha256=bxpXHehbuEanYqfzjUPYvPDw8Kbc_K32nPKMEIzaWiA,4114
8
+ primate_intelligence-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
9
+ primate_intelligence-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any