primate-intelligence 0.1.0__tar.gz → 0.2.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,23 @@
1
+ # Changelog — primate-intelligence (Python SDK)
2
+
3
+ ## 0.2.0 — 2026-07-28
4
+
5
+ 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.
6
+
7
+ ### Added
8
+
9
+ - `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.
10
+ - `client.credit_pricing.retrieve()` — `GET /v1/credit-pricing` (public): `price_per_second_cents`, grants, purchase presets.
11
+ - `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.
12
+ - `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.
13
+ - `client.analyses.validate_batch(...)` — batch dry-run returning `analysis_batch_preview` with per-prompt `discount_pct` (0 or 50) and estimates.
14
+ - Auto `Idempotency-Key` now also covers `POST /v1/analyses/batch`.
15
+
16
+ ### Documentation (docstrings — dict responses, no code change needed)
17
+
18
+ - `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).
19
+ - `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).
20
+
21
+ ## 0.1.0
22
+
23
+ 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.2.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,
@@ -225,6 +232,13 @@
225
232
  "idem": false,
226
233
  "docs_url": "https://primateintelligence.ai/docs/errors#insufficient_credits"
227
234
  },
235
+ "grant_exhausted": {
236
+ "kind": "http",
237
+ "status": 402,
238
+ "retryable": false,
239
+ "idem": false,
240
+ "docs_url": "https://primateintelligence.ai/docs/errors#grant_exhausted"
241
+ },
228
242
  "capacity_exhausted": {
229
243
  "kind": "http",
230
244
  "status": 503,
@@ -239,6 +253,20 @@
239
253
  "idem": true,
240
254
  "docs_url": "https://primateintelligence.ai/docs/errors#sandbox_limit_exceeded"
241
255
  },
256
+ "upgrade_limit_exceeded": {
257
+ "kind": "http",
258
+ "status": 429,
259
+ "retryable": false,
260
+ "idem": false,
261
+ "docs_url": "https://primateintelligence.ai/docs/errors#upgrade_limit_exceeded"
262
+ },
263
+ "device_code_expired": {
264
+ "kind": "http",
265
+ "status": 410,
266
+ "retryable": false,
267
+ "idem": false,
268
+ "docs_url": "https://primateintelligence.ai/docs/errors#device_code_expired"
269
+ },
242
270
  "inference_unavailable": {
243
271
  "kind": "http",
244
272
  "status": 503,
@@ -246,6 +274,13 @@
246
274
  "idem": true,
247
275
  "docs_url": "https://primateintelligence.ai/docs/errors#inference_unavailable"
248
276
  },
277
+ "upstream_error": {
278
+ "kind": "http",
279
+ "status": 502,
280
+ "retryable": false,
281
+ "idem": false,
282
+ "docs_url": "https://primateintelligence.ai/docs/errors#upstream_error"
283
+ },
249
284
  "db_unavailable": {
250
285
  "kind": "http",
251
286
  "status": 503,
@@ -70,9 +70,59 @@ class Analyses:
70
70
  self._http = http
71
71
 
72
72
  def create(self, **params: Any) -> dict[str, Any]:
73
- """POST /v1/analyses."""
73
+ """POST /v1/analyses.
74
+
75
+ Pass ``validate_only=True`` for a dry run: compiles the prompt, reports
76
+ whether the query is assessable, and estimates duration/cost — WITHOUT
77
+ creating an analysis or spending credits. The response is then an
78
+ ``analysis_preview`` object (``{object, query, parse_mode, assessable,
79
+ video_duration_s, estimated_seconds, estimated_cost_usd}``), not an
80
+ analysis. Prefer :meth:`validate_only` which reads more clearly.
81
+ """
74
82
  return self._http.post("/v1/analyses", json_body=params)
75
83
 
84
+ def validate_only(self, **params: Any) -> dict[str, Any]:
85
+ """Dry-run a prompt (POST /v1/analyses with ``validate_only: true``).
86
+
87
+ Costs nothing: no analysis is created, no credits spent. Returns an
88
+ ``analysis_preview`` dict::
89
+
90
+ {object: "analysis_preview", query, parse_mode: "llm"|"heuristic"|"client",
91
+ assessable: bool, video_duration_s: float|None,
92
+ estimated_seconds: int|None, estimated_cost_usd: float|None}
93
+
94
+ Estimates are None when the video duration is not yet known.
95
+ """
96
+ params["validate_only"] = True
97
+ return self._http.post("/v1/analyses", json_body=params)
98
+
99
+ def create_batch(self, **params: Any) -> dict[str, Any]:
100
+ """POST /v1/analyses/batch — 2–10 prompts against the same video.
101
+
102
+ First prompt is billed at full price; each additional prompt at 50%.
103
+ Required: ``video_id`` and ``prompts`` (list of 2–10 strings).
104
+ Returns an ``analysis_batch``::
105
+
106
+ {object: "analysis_batch", id, video_id, analyses: […], pricing:
107
+ {full_price_prompts, discounted_prompts, discount_pct,
108
+ estimated_total_seconds, estimated_total_cost_usd}}
109
+
110
+ Poll each returned analysis id individually. Pass ``validate_only=True``
111
+ (or use :meth:`validate_batch`) for a cost-estimate dry-run that returns
112
+ an ``analysis_batch_preview`` with per-prompt ``discount_pct`` (0 or 50)
113
+ and estimates — no credits reserved, no jobs created.
114
+ """
115
+ return self._http.post("/v1/analyses/batch", json_body=params)
116
+
117
+ def validate_batch(self, **params: Any) -> dict[str, Any]:
118
+ """Dry-run a batch (POST /v1/analyses/batch with ``validate_only: true``).
119
+
120
+ Parses all prompts and returns an ``analysis_batch_preview`` with
121
+ per-prompt estimates. No credits reserved, no jobs created.
122
+ """
123
+ params["validate_only"] = True
124
+ return self._http.post("/v1/analyses/batch", json_body=params)
125
+
76
126
  def retrieve(self, analysis_id: str) -> dict[str, Any]:
77
127
  return self._http.get(f"/v1/analyses/{analysis_id}")
78
128
 
@@ -96,6 +146,15 @@ class Analyses:
96
146
 
97
147
  Uses ``Prefer: wait`` server-side sync sugar for the first leg
98
148
  (cap 120s), then polls with the given interval until ``timeout_s``.
149
+
150
+ On completion, ``result`` carries ``answer`` (yes|no|indeterminate),
151
+ ``confidence``, ``clips``, ``video_duration_s`` (may be None), and —
152
+ when relevant — ``detected_count`` (count-intent queries; 0 = nothing
153
+ found) and ``indeterminate_reason`` (low_confidence |
154
+ nothing_detected | unsupported_query_form | duration_mismatch).
155
+ ``usage`` (terminal only) is ``{billed_seconds, credit_balance_after}``
156
+ — an immutable snapshot of the balance after THIS analysis settled.
157
+ Do not pass ``validate_only`` here — use :meth:`validate_only`.
99
158
  """
100
159
  prefer_wait = min(int(timeout_s), 120)
101
160
  deadline = time.monotonic() + timeout_s
@@ -130,7 +189,27 @@ class Streams:
130
189
  return self._http.paginate("/v1/streams", params=params)
131
190
 
132
191
  def end(self, stream_id: str) -> dict[str, Any]:
133
- """POST /v1/streams/{id}/end (idempotent)."""
192
+ """POST /v1/streams/{id}/end (idempotent).
193
+
194
+ The terminal stream resource carries:
195
+
196
+ - ``end_reason``: completed | canceled | insufficient_credits | error |
197
+ timeout | ice_failed | media_timeout. A stream that never went live
198
+ is never "completed"; ice_failed/media_timeout/canceled bill 0.
199
+ - ``results_summary``: ``{result_frames, frames (deprecated alias,
200
+ removed ~2026-08-28 — read result_frames), last_detections}``.
201
+ result_frames counts result EVENTS emitted (sampled, not per-frame).
202
+ - ``failure_diagnostic``: on ice_failed (and some media_timeout cases),
203
+ ``{local_candidates, hint}`` — the server-side ICE candidate summary.
204
+ - ``usage``: ``{billed_seconds, credit_balance_after}`` — live-clock
205
+ seconds; join/negotiation free.
206
+
207
+ While live, the signaling WS sends 5s metering ticks
208
+ ``{elapsed_s, billed_s, session_remaining_s}`` (``balance_s`` is a
209
+ deprecated alias of session_remaining_s, removed ~2026-08-28) and may
210
+ push a ``warning`` with ``code: "no_media_frames"`` when transport
211
+ connected but no decodable frames arrived.
212
+ """
134
213
  return self._http.post(f"/v1/streams/{stream_id}/end")
135
214
 
136
215
 
@@ -156,10 +235,57 @@ class Usage:
156
235
  self._http = http
157
236
 
158
237
  def retrieve(self) -> dict[str, Any]:
159
- """GET /v1/usage — credit balance + period meters."""
238
+ """GET /v1/usage — credit balance + period meters.
239
+
240
+ For the per-analysis transaction ledger, use :class:`Credits`
241
+ (``client.credits.retrieve()``).
242
+ """
160
243
  return self._http.get("/v1/usage")
161
244
 
162
245
 
246
+ class Credits:
247
+ def __init__(self, http: HttpClient) -> None:
248
+ self._http = http
249
+
250
+ def retrieve(self, *, limit: Optional[int] = None, before: Optional[str] = None) -> dict[str, Any]:
251
+ """GET /v1/credits — balance + paginated transaction ledger.
252
+
253
+ Agent-facing billing truth: check this before submitting work to avoid
254
+ a 402. Returns::
255
+
256
+ {object: "credits", balance_seconds, grant_seconds, used_seconds,
257
+ transactions: [{id, kind, seconds_delta, balance_after_seconds,
258
+ source_type, source_id, created_at}],
259
+ has_more: bool}
260
+
261
+ Each debit carries the analysis/stream id it came from in
262
+ ``source_id`` (``source_type: "analysis"``). ``limit`` is 1–100
263
+ (default 20); ``before`` is a cursor returning transactions older
264
+ than that id.
265
+ """
266
+ params: dict[str, Any] = {}
267
+ if limit is not None:
268
+ params["limit"] = limit
269
+ if before is not None:
270
+ params["before"] = before
271
+ return self._http.get("/v1/credits", params=params or None)
272
+
273
+
274
+ class CreditPricing:
275
+ def __init__(self, http: HttpClient) -> None:
276
+ self._http = http
277
+
278
+ def retrieve(self) -> dict[str, Any]:
279
+ """GET /v1/credit-pricing — current pricing config (public, no auth).
280
+
281
+ Returns ``{price_per_second_cents, signup_grant_seconds,
282
+ card_grant_seconds, auto_refill_threshold_seconds,
283
+ allowed_purchase_cents, custom_purchase_enabled, min_purchase_cents}``.
284
+ Cost of an analysis = ``usage.billed_seconds × price_per_second_cents``.
285
+ """
286
+ return self._http.get("/v1/credit-pricing")
287
+
288
+
163
289
  class Errors:
164
290
  def __init__(self, http: HttpClient) -> None:
165
291
  self._http = http
@@ -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.2.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