primate-intelligence 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.
- primate_intelligence-0.1.0/.gitignore +5 -0
- primate_intelligence-0.1.0/PKG-INFO +109 -0
- primate_intelligence-0.1.0/README.md +87 -0
- primate_intelligence-0.1.0/primate_intelligence/__init__.py +82 -0
- primate_intelligence-0.1.0/primate_intelligence/_client.py +157 -0
- primate_intelligence-0.1.0/primate_intelligence/_error_registry.json +285 -0
- primate_intelligence-0.1.0/primate_intelligence/_errors.py +62 -0
- primate_intelligence-0.1.0/primate_intelligence/_resources.py +210 -0
- primate_intelligence-0.1.0/primate_intelligence/webhooks.py +72 -0
- primate_intelligence-0.1.0/pyproject.toml +32 -0
- primate_intelligence-0.1.0/tests/test_sdk.py +198 -0
|
@@ -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,87 @@
|
|
|
1
|
+
# primate-intelligence
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
> **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.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install primate-intelligence
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires Python 3.10+.
|
|
14
|
+
|
|
15
|
+
## Quickstart
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from primate_intelligence import Primate
|
|
19
|
+
|
|
20
|
+
client = Primate() # reads PRIMATE_API_KEY from the environment
|
|
21
|
+
|
|
22
|
+
video = client.videos.create(url="https://example.com/clip.mp4")
|
|
23
|
+
analysis = client.analyses.create_and_wait(
|
|
24
|
+
video_id=video["id"],
|
|
25
|
+
prompt="Is there a person in this video?",
|
|
26
|
+
)
|
|
27
|
+
print(analysis["result"]["answer"], analysis["result"]["confidence"])
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Get a free test key with no signup:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
curl -X POST https://api.primateintelligence.ai/v1/sandbox
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Features
|
|
37
|
+
|
|
38
|
+
- **Typed resource namespaces** for the full `/v1` surface: `videos`, `analyses`, `streams`, `client_tokens`, `models`, `usage`, `errors`, `webhook_endpoints`.
|
|
39
|
+
- **Automatic retries** driven by the [error registry](https://primateintelligence.ai/docs/errors) `retryable` flags + `Retry-After`.
|
|
40
|
+
- **Auto idempotency keys** (UUIDv4) on all create calls.
|
|
41
|
+
- **`create_and_wait()`** — create an analysis and block until a terminal state (uses `Prefer: wait` server-side, then polls).
|
|
42
|
+
- **`videos.upload()`** — create + presigned S3 PUT + complete, in one call.
|
|
43
|
+
- **Pagination iterators** — `for video in client.videos.iter(): …`.
|
|
44
|
+
- **Webhook verification** — `verify_webhook()` implements [Standard Webhooks](https://www.standardwebhooks.com) with constant-time compare + replay-window checks.
|
|
45
|
+
|
|
46
|
+
## Error handling
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from primate_intelligence import Primate, PrimateError
|
|
50
|
+
|
|
51
|
+
client = Primate()
|
|
52
|
+
try:
|
|
53
|
+
client.analyses.create(video_id="video_x", prompt="hi")
|
|
54
|
+
except PrimateError as err:
|
|
55
|
+
print(err.code, err.status, err.request_id, err.docs_url)
|
|
56
|
+
if err.code == "insufficient_credits":
|
|
57
|
+
usage = client.usage.retrieve()
|
|
58
|
+
# → prompt the account owner to top up or enable auto-refill
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
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.
|
|
62
|
+
|
|
63
|
+
## Webhooks
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from primate_intelligence import verify_webhook_request
|
|
67
|
+
|
|
68
|
+
@app.post("/webhooks/primate")
|
|
69
|
+
async def receive(request: Request):
|
|
70
|
+
raw = await request.body()
|
|
71
|
+
verify_webhook_request(
|
|
72
|
+
secret=os.environ["PRIMATE_WEBHOOK_SECRET"], # whsec_…
|
|
73
|
+
headers=dict(request.headers),
|
|
74
|
+
raw_body=raw,
|
|
75
|
+
) # raises ValueError on bad signature / stale timestamp
|
|
76
|
+
# dedupe on the webhook-id header — delivery is at-least-once
|
|
77
|
+
return Response(status_code=204)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
| Kwarg | Env var | Default |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `api_key` | `PRIMATE_API_KEY` | — (required) |
|
|
85
|
+
| `base_url` | `PRIMATE_BASE_URL` | `https://api.primateintelligence.ai` |
|
|
86
|
+
| `max_retries` | — | `3` |
|
|
87
|
+
| `timeout_s` | — | `60.0` |
|
|
@@ -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,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "primate-intelligence"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the Primate Vision API (Primate Intelligence). Video scene understanding: upload, analyze, stream."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Primate Intelligence" }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = ["httpx>=0.25,<1"]
|
|
14
|
+
keywords = ["video", "vision", "ai", "video-understanding", "primate-intelligence"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://primateintelligence.ai/docs"
|
|
26
|
+
Repository = "https://github.com/Primate-Intelligence/primate-intelligence-api"
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = ["pytest>=7", "respx>=0.21"]
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["primate_intelligence"]
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Unit tests for the primate-intelligence Python SDK (offline, httpx MockTransport)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from primate_intelligence import Primate, PrimateError, PrimateTimeoutError, registry_flags
|
|
13
|
+
from primate_intelligence.webhooks import verify_webhook, verify_webhook_request
|
|
14
|
+
|
|
15
|
+
SECRET = "whsec_" + base64.b64encode(b"test-secret-32-bytes-aaaaaaaaaaa").decode()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _client(handler) -> Primate:
|
|
19
|
+
return Primate(api_key="pv_test_k", transport=httpx.MockTransport(handler), max_retries=3)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _err(code: str, status: int, **extra) -> httpx.Response:
|
|
23
|
+
return httpx.Response(
|
|
24
|
+
status,
|
|
25
|
+
json={"error": {"code": code, "message": f"msg {code}", "status": status, "request_id": "req_t", **extra}},
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ── Auth / construction ─────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_requires_api_key(monkeypatch):
|
|
33
|
+
monkeypatch.delenv("PRIMATE_API_KEY", raising=False)
|
|
34
|
+
with pytest.raises(ValueError, match="PRIMATE_API_KEY"):
|
|
35
|
+
Primate()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_sends_bearer_and_auto_idempotency_key():
|
|
39
|
+
seen = {}
|
|
40
|
+
|
|
41
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
42
|
+
seen["auth"] = request.headers.get("Authorization")
|
|
43
|
+
seen["idem"] = request.headers.get("Idempotency-Key")
|
|
44
|
+
return httpx.Response(200, json={"id": "video_x", "object": "video"})
|
|
45
|
+
|
|
46
|
+
c = _client(handler)
|
|
47
|
+
c.videos.create(url="https://example.com/v.mp4")
|
|
48
|
+
assert seen["auth"] == "Bearer pv_test_k"
|
|
49
|
+
assert seen["idem"] and len(seen["idem"]) == 36 # UUIDv4
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_no_auto_key_on_cancel():
|
|
53
|
+
seen = {}
|
|
54
|
+
|
|
55
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
56
|
+
seen["idem"] = request.headers.get("Idempotency-Key")
|
|
57
|
+
return httpx.Response(200, json={"id": "an_x", "object": "analysis", "status": "canceled"})
|
|
58
|
+
|
|
59
|
+
_client(handler).analyses.cancel("an_x")
|
|
60
|
+
assert seen["idem"] is None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── Retry policy (registry-driven) ───────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_retries_retryable_then_succeeds():
|
|
67
|
+
calls = {"n": 0}
|
|
68
|
+
|
|
69
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
70
|
+
calls["n"] += 1
|
|
71
|
+
if calls["n"] == 1:
|
|
72
|
+
return _err("inference_unavailable", 503, docs_url="x")
|
|
73
|
+
return httpx.Response(200, json={"object": "list", "data": [], "has_more": False})
|
|
74
|
+
|
|
75
|
+
out = _client(handler).models.list()
|
|
76
|
+
assert out["object"] == "list"
|
|
77
|
+
assert calls["n"] == 2
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_does_not_retry_non_retryable():
|
|
81
|
+
calls = {"n": 0}
|
|
82
|
+
|
|
83
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
84
|
+
calls["n"] += 1
|
|
85
|
+
return _err("invalid_api_key", 401)
|
|
86
|
+
|
|
87
|
+
with pytest.raises(PrimateError) as ei:
|
|
88
|
+
_client(handler).models.list()
|
|
89
|
+
assert calls["n"] == 1
|
|
90
|
+
assert ei.value.code == "invalid_api_key"
|
|
91
|
+
assert ei.value.retryable is False
|
|
92
|
+
assert ei.value.request_id == "req_t"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_registry_flags():
|
|
96
|
+
assert registry_flags("rate_limit_exceeded")["retryable"] is True
|
|
97
|
+
assert registry_flags("capacity_exhausted")["idem"] is True
|
|
98
|
+
assert registry_flags("validation_failed")["retryable"] is False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── create_and_wait ─────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_create_and_wait_prefers_wait_and_polls():
|
|
105
|
+
calls = []
|
|
106
|
+
|
|
107
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
108
|
+
calls.append((request.method, request.url.path, request.headers.get("Prefer")))
|
|
109
|
+
if request.method == "POST":
|
|
110
|
+
return httpx.Response(200, json={"id": "an_1", "object": "analysis", "status": "queued"})
|
|
111
|
+
n = len([c for c in calls if c[0] == "GET"])
|
|
112
|
+
status = "completed" if n >= 2 else "analyzing"
|
|
113
|
+
return httpx.Response(200, json={"id": "an_1", "object": "analysis", "status": status})
|
|
114
|
+
|
|
115
|
+
a = _client(handler).analyses.create_and_wait(
|
|
116
|
+
video_id="video_x", prompt="p", poll_interval_s=0.001
|
|
117
|
+
)
|
|
118
|
+
assert a["status"] == "completed"
|
|
119
|
+
assert calls[0][2] == "wait=120" # Prefer header on the create
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_create_and_wait_timeout():
|
|
123
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
124
|
+
return httpx.Response(200, json={"id": "an_1", "object": "analysis", "status": "analyzing"})
|
|
125
|
+
|
|
126
|
+
with pytest.raises(PrimateTimeoutError):
|
|
127
|
+
_client(handler).analyses.create_and_wait(
|
|
128
|
+
video_id="video_x", prompt="p", timeout_s=0.01, poll_interval_s=0.001
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── Pagination ──────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_paginate_cursors():
|
|
136
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
137
|
+
after = request.url.params.get("starting_after")
|
|
138
|
+
if after is None:
|
|
139
|
+
return httpx.Response(
|
|
140
|
+
200, json={"object": "list", "data": [{"id": "v_1"}, {"id": "v_2"}], "has_more": True}
|
|
141
|
+
)
|
|
142
|
+
assert after == "v_2"
|
|
143
|
+
return httpx.Response(200, json={"object": "list", "data": [{"id": "v_3"}], "has_more": False})
|
|
144
|
+
|
|
145
|
+
ids = [v["id"] for v in _client(handler).videos.iter()]
|
|
146
|
+
assert ids == ["v_1", "v_2", "v_3"]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ── Webhook verification (§16.3 test vectors) ───────────────────────────────────
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _sign(id_: str, ts: str, payload: str, secret: str = SECRET) -> str:
|
|
153
|
+
key = base64.b64decode(secret.removeprefix("whsec_"))
|
|
154
|
+
mac = hmac.new(key, f"{id_}.{ts}.{payload}".encode(), hashlib.sha256).digest()
|
|
155
|
+
return "v1," + base64.b64encode(mac).decode()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
NOW = 1_751_980_800
|
|
159
|
+
EVT = "evt_01TESTTESTTESTTESTTESTTEST"
|
|
160
|
+
PAYLOAD = json.dumps({"id": EVT, "type": "analysis.completed"})
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_webhook_valid():
|
|
164
|
+
assert verify_webhook(
|
|
165
|
+
secret=SECRET, id=EVT, timestamp=str(NOW), signature=_sign(EVT, str(NOW), PAYLOAD),
|
|
166
|
+
payload=PAYLOAD, now_s=NOW,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_webhook_tampered_payload():
|
|
171
|
+
assert not verify_webhook(
|
|
172
|
+
secret=SECRET, id=EVT, timestamp=str(NOW), signature=_sign(EVT, str(NOW), PAYLOAD),
|
|
173
|
+
payload=PAYLOAD + "x", now_s=NOW,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_webhook_replay_window():
|
|
178
|
+
assert not verify_webhook(
|
|
179
|
+
secret=SECRET, id=EVT, timestamp=str(NOW), signature=_sign(EVT, str(NOW), PAYLOAD),
|
|
180
|
+
payload=PAYLOAD, now_s=NOW + 301,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_webhook_rotation_any_match():
|
|
185
|
+
sig = "v1,AAAA " + _sign(EVT, str(NOW), PAYLOAD)
|
|
186
|
+
assert verify_webhook(
|
|
187
|
+
secret=SECRET, id=EVT, timestamp=str(NOW), signature=sig, payload=PAYLOAD, now_s=NOW
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_webhook_request_helper_raises():
|
|
192
|
+
with pytest.raises(ValueError, match="verification failed"):
|
|
193
|
+
verify_webhook_request(
|
|
194
|
+
secret=SECRET,
|
|
195
|
+
headers={"Webhook-Id": EVT, "Webhook-Timestamp": str(NOW), "Webhook-Signature": "v1,AAAA"},
|
|
196
|
+
raw_body=PAYLOAD,
|
|
197
|
+
now_s=NOW,
|
|
198
|
+
)
|