genrelay 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ *.mp4
12
+ *.part
genrelay-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GenRelay
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,191 @@
1
+ Metadata-Version: 2.5
2
+ Name: genrelay
3
+ Version: 0.1.0
4
+ Summary: Unified Python client for Veo 3.1, Grok Imagine, Nano Banana and other video & image generation models via the GenRelay API
5
+ Project-URL: Homepage, https://genrelay.ai
6
+ Project-URL: Documentation, https://genrelay.ai/docs
7
+ Project-URL: Source, https://github.com/genrelay/genrelay-python
8
+ Project-URL: Issues, https://github.com/genrelay/genrelay-python/issues
9
+ Author: GenRelay
10
+ License: MIT License
11
+
12
+ Copyright (c) 2026 GenRelay
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
31
+ License-File: LICENSE
32
+ Keywords: api-client,generative-ai,grok-imagine,image-generation,image-to-video,nano-banana,text-to-video,veo,veo-3,video-generation
33
+ Classifier: Development Status :: 4 - Beta
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.9
38
+ Classifier: Programming Language :: Python :: 3.10
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: Programming Language :: Python :: 3.12
41
+ Classifier: Programming Language :: Python :: 3.13
42
+ Classifier: Topic :: Multimedia :: Video
43
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
44
+ Classifier: Typing :: Typed
45
+ Requires-Python: >=3.9
46
+ Requires-Dist: httpx>=0.24
47
+ Provides-Extra: dev
48
+ Requires-Dist: mypy; extra == 'dev'
49
+ Requires-Dist: pytest-cov; extra == 'dev'
50
+ Requires-Dist: pytest>=7; extra == 'dev'
51
+ Requires-Dist: ruff; extra == 'dev'
52
+ Description-Content-Type: text/markdown
53
+
54
+ # genrelay-python
55
+
56
+ Python client for [GenRelay](https://genrelay.ai) — one API for **Veo 3.1**, **Grok Imagine**, **Nano Banana Pro/2**, **GPT Image 2** and **Omni Flash**. Text-to-video, image-to-video, first/last-frame, and image generation.
57
+
58
+ ```bash
59
+ pip install genrelay
60
+ ```
61
+
62
+ ## Quick start
63
+
64
+ ```python
65
+ from genrelay import GenRelay
66
+
67
+ client = GenRelay() # reads GENRELAY_API_KEY
68
+
69
+ video = client.videos.generate(
70
+ model="veo_3_1",
71
+ prompt="a neon fox running through a rainy city at night",
72
+ seconds=8,
73
+ size="1280x720",
74
+ tier="1080p",
75
+ )
76
+
77
+ client.videos.download(video.id, "fox.mp4")
78
+ ```
79
+
80
+ That's the whole loop: submit, poll with backoff, stream to disk. Generation
81
+ takes seconds to minutes depending on the model and length.
82
+
83
+ ## Why this exists
84
+
85
+ Chat and image edits on GenRelay are **OpenAI-compatible** — point the
86
+ official `openai` package at `https://genrelay.ai/v1` and you're done:
87
+
88
+ ```python
89
+ from openai import OpenAI
90
+ client = OpenAI(base_url="https://genrelay.ai/v1", api_key="sk-...")
91
+ ```
92
+
93
+ Generation jobs are different: they're asynchronous, with a submit → poll →
94
+ download cycle that has no equivalent in that SDK. That's what this package
95
+ handles — the polling loop, the backoff, the terminal-state detection, the
96
+ streamed download, and typed errors instead of dict-digging.
97
+
98
+ ## Async, when you need control
99
+
100
+ ```python
101
+ job = client.videos.create(model="veo_3_1", prompt="...", seconds=8)
102
+ print(job.id, job.status) # task_xxx queued
103
+
104
+ # ... do other work, persist job.id, come back later ...
105
+
106
+ video = client.videos.wait(job.id, timeout=900)
107
+ ```
108
+
109
+ Watch progress while it runs:
110
+
111
+ ```python
112
+ client.videos.generate(
113
+ model="veo_3_1",
114
+ prompt="...",
115
+ on_progress=lambda v: print(f"{v.status} {v.progress}%"),
116
+ )
117
+ ```
118
+
119
+ ## Models
120
+
121
+ | Model | Kind | Notes |
122
+ |---|---|---|
123
+ | `veo_3_1` | video | Google Veo 3.1 |
124
+ | `veo_3_1-fl` | video | first/last frame |
125
+ | `veo_3_1-components` | video | component-guided |
126
+ | `grok-imagine-video-1-5-preview` | video | xAI Grok Imagine 1.5 |
127
+ | `grok-imagine-1-0-video` | video | Grok Imagine 1.0 |
128
+ | `omni-flash` | video | fast, long clips |
129
+ | `omni_flash_abra_edit` | video | video editing |
130
+ | `nano-banana-pro` | image | Google |
131
+ | `nano-banana-2` | image | Google |
132
+ | `gpt-image-2` | image | OpenAI |
133
+
134
+ Current list and per-model options: **https://genrelay.ai/models**
135
+
136
+ ## Parameters
137
+
138
+ | Name | Type | Notes |
139
+ |---|---|---|
140
+ | `model` | str | required |
141
+ | `prompt` | str | required |
142
+ | `seconds` | int \| str | 1–60, default 4. Ignored by image models |
143
+ | `size` | str | `"1280x720"`. Aspect ratio is inferred — no separate parameter |
144
+ | `tier` | str | `1k`/`2k`/`4k` (images), `720p`/`1080p`/`4k` (Veo) |
145
+ | `reference_images` | list[str] | HTTPS or `data:` URLs |
146
+ | `metadata` | dict | model-specific extras, e.g. `{"first_last_frame": True}` |
147
+
148
+ > **Set `tier` explicitly.** Pricing is per tier, and a `size` that matches no
149
+ > tier falls back to the model's base rate — which can cost noticeably more
150
+ > than you expected.
151
+
152
+ Unknown keyword arguments are forwarded as-is, so new API fields work before
153
+ this client knows about them.
154
+
155
+ ## Errors
156
+
157
+ ```python
158
+ from genrelay import InsufficientCreditsError, JobFailedError, JobTimeout
159
+
160
+ try:
161
+ video = client.videos.generate(model="veo_3_1", prompt="...")
162
+ except InsufficientCreditsError:
163
+ ... # 402 — top up
164
+ except JobFailedError as e:
165
+ print(e.video.error) # model couldn't produce a result
166
+ except JobTimeout as e:
167
+ print(e.task_id) # still running — poll later, don't resubmit
168
+ ```
169
+
170
+ `JobTimeout` deliberately keeps the task id: resubmitting bills a second time,
171
+ polling doesn't.
172
+
173
+ All exceptions derive from `GenRelayError`.
174
+
175
+ ## Configuration
176
+
177
+ | | |
178
+ |---|---|
179
+ | `GENRELAY_API_KEY` | API key, or pass `api_key=` |
180
+ | `GENRELAY_BASE_URL` | override the endpoint (default `https://genrelay.ai/v1`) |
181
+
182
+ ```python
183
+ client = GenRelay(api_key="sk-...", timeout=60.0, max_retries=2)
184
+ ```
185
+
186
+ `GenRelay` is a context manager and closes its HTTP client on exit. Pass your
187
+ own `httpx.Client` via `http_client=` to control pooling or proxies.
188
+
189
+ ## License
190
+
191
+ MIT
@@ -0,0 +1,138 @@
1
+ # genrelay-python
2
+
3
+ Python client for [GenRelay](https://genrelay.ai) — one API for **Veo 3.1**, **Grok Imagine**, **Nano Banana Pro/2**, **GPT Image 2** and **Omni Flash**. Text-to-video, image-to-video, first/last-frame, and image generation.
4
+
5
+ ```bash
6
+ pip install genrelay
7
+ ```
8
+
9
+ ## Quick start
10
+
11
+ ```python
12
+ from genrelay import GenRelay
13
+
14
+ client = GenRelay() # reads GENRELAY_API_KEY
15
+
16
+ video = client.videos.generate(
17
+ model="veo_3_1",
18
+ prompt="a neon fox running through a rainy city at night",
19
+ seconds=8,
20
+ size="1280x720",
21
+ tier="1080p",
22
+ )
23
+
24
+ client.videos.download(video.id, "fox.mp4")
25
+ ```
26
+
27
+ That's the whole loop: submit, poll with backoff, stream to disk. Generation
28
+ takes seconds to minutes depending on the model and length.
29
+
30
+ ## Why this exists
31
+
32
+ Chat and image edits on GenRelay are **OpenAI-compatible** — point the
33
+ official `openai` package at `https://genrelay.ai/v1` and you're done:
34
+
35
+ ```python
36
+ from openai import OpenAI
37
+ client = OpenAI(base_url="https://genrelay.ai/v1", api_key="sk-...")
38
+ ```
39
+
40
+ Generation jobs are different: they're asynchronous, with a submit → poll →
41
+ download cycle that has no equivalent in that SDK. That's what this package
42
+ handles — the polling loop, the backoff, the terminal-state detection, the
43
+ streamed download, and typed errors instead of dict-digging.
44
+
45
+ ## Async, when you need control
46
+
47
+ ```python
48
+ job = client.videos.create(model="veo_3_1", prompt="...", seconds=8)
49
+ print(job.id, job.status) # task_xxx queued
50
+
51
+ # ... do other work, persist job.id, come back later ...
52
+
53
+ video = client.videos.wait(job.id, timeout=900)
54
+ ```
55
+
56
+ Watch progress while it runs:
57
+
58
+ ```python
59
+ client.videos.generate(
60
+ model="veo_3_1",
61
+ prompt="...",
62
+ on_progress=lambda v: print(f"{v.status} {v.progress}%"),
63
+ )
64
+ ```
65
+
66
+ ## Models
67
+
68
+ | Model | Kind | Notes |
69
+ |---|---|---|
70
+ | `veo_3_1` | video | Google Veo 3.1 |
71
+ | `veo_3_1-fl` | video | first/last frame |
72
+ | `veo_3_1-components` | video | component-guided |
73
+ | `grok-imagine-video-1-5-preview` | video | xAI Grok Imagine 1.5 |
74
+ | `grok-imagine-1-0-video` | video | Grok Imagine 1.0 |
75
+ | `omni-flash` | video | fast, long clips |
76
+ | `omni_flash_abra_edit` | video | video editing |
77
+ | `nano-banana-pro` | image | Google |
78
+ | `nano-banana-2` | image | Google |
79
+ | `gpt-image-2` | image | OpenAI |
80
+
81
+ Current list and per-model options: **https://genrelay.ai/models**
82
+
83
+ ## Parameters
84
+
85
+ | Name | Type | Notes |
86
+ |---|---|---|
87
+ | `model` | str | required |
88
+ | `prompt` | str | required |
89
+ | `seconds` | int \| str | 1–60, default 4. Ignored by image models |
90
+ | `size` | str | `"1280x720"`. Aspect ratio is inferred — no separate parameter |
91
+ | `tier` | str | `1k`/`2k`/`4k` (images), `720p`/`1080p`/`4k` (Veo) |
92
+ | `reference_images` | list[str] | HTTPS or `data:` URLs |
93
+ | `metadata` | dict | model-specific extras, e.g. `{"first_last_frame": True}` |
94
+
95
+ > **Set `tier` explicitly.** Pricing is per tier, and a `size` that matches no
96
+ > tier falls back to the model's base rate — which can cost noticeably more
97
+ > than you expected.
98
+
99
+ Unknown keyword arguments are forwarded as-is, so new API fields work before
100
+ this client knows about them.
101
+
102
+ ## Errors
103
+
104
+ ```python
105
+ from genrelay import InsufficientCreditsError, JobFailedError, JobTimeout
106
+
107
+ try:
108
+ video = client.videos.generate(model="veo_3_1", prompt="...")
109
+ except InsufficientCreditsError:
110
+ ... # 402 — top up
111
+ except JobFailedError as e:
112
+ print(e.video.error) # model couldn't produce a result
113
+ except JobTimeout as e:
114
+ print(e.task_id) # still running — poll later, don't resubmit
115
+ ```
116
+
117
+ `JobTimeout` deliberately keeps the task id: resubmitting bills a second time,
118
+ polling doesn't.
119
+
120
+ All exceptions derive from `GenRelayError`.
121
+
122
+ ## Configuration
123
+
124
+ | | |
125
+ |---|---|
126
+ | `GENRELAY_API_KEY` | API key, or pass `api_key=` |
127
+ | `GENRELAY_BASE_URL` | override the endpoint (default `https://genrelay.ai/v1`) |
128
+
129
+ ```python
130
+ client = GenRelay(api_key="sk-...", timeout=60.0, max_retries=2)
131
+ ```
132
+
133
+ `GenRelay` is a context manager and closes its HTTP client on exit. Pass your
134
+ own `httpx.Client` via `http_client=` to control pooling or proxies.
135
+
136
+ ## License
137
+
138
+ MIT
@@ -0,0 +1,68 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "genrelay"
7
+ version = "0.1.0"
8
+ description = "Unified Python client for Veo 3.1, Grok Imagine, Nano Banana and other video & image generation models via the GenRelay API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "GenRelay" }]
13
+ keywords = [
14
+ "veo",
15
+ "veo-3",
16
+ "grok-imagine",
17
+ "nano-banana",
18
+ "text-to-video",
19
+ "image-to-video",
20
+ "video-generation",
21
+ "image-generation",
22
+ "generative-ai",
23
+ "api-client",
24
+ ]
25
+ classifiers = [
26
+ "Development Status :: 4 - Beta",
27
+ "Intended Audience :: Developers",
28
+ "License :: OSI Approved :: MIT License",
29
+ "Programming Language :: Python :: 3",
30
+ "Programming Language :: Python :: 3.9",
31
+ "Programming Language :: Python :: 3.10",
32
+ "Programming Language :: Python :: 3.11",
33
+ "Programming Language :: Python :: 3.12",
34
+ "Programming Language :: Python :: 3.13",
35
+ "Topic :: Multimedia :: Video",
36
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
37
+ "Typing :: Typed",
38
+ ]
39
+ dependencies = ["httpx>=0.24"]
40
+
41
+ [project.urls]
42
+ Homepage = "https://genrelay.ai"
43
+ Documentation = "https://genrelay.ai/docs"
44
+ Source = "https://github.com/genrelay/genrelay-python"
45
+ Issues = "https://github.com/genrelay/genrelay-python/issues"
46
+
47
+ [project.optional-dependencies]
48
+ dev = ["pytest>=7", "pytest-cov", "ruff", "mypy"]
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["src/genrelay"]
52
+
53
+ [tool.pytest.ini_options]
54
+ testpaths = ["tests"]
55
+ # src layout: let the tests import the package without an editable install,
56
+ # so `pytest` works straight after a clone.
57
+ pythonpath = ["src"]
58
+
59
+ [tool.ruff]
60
+ line-length = 100
61
+ target-version = "py39"
62
+
63
+ [tool.ruff.lint]
64
+ select = ["E", "F", "I", "UP", "B"]
65
+
66
+ [tool.mypy]
67
+ python_version = "3.9"
68
+ strict = true
@@ -0,0 +1,42 @@
1
+ """GenRelay — unified API client for Veo 3.1, Grok Imagine, Nano Banana and more.
2
+
3
+ Generation jobs are asynchronous: you submit, the server works for anywhere
4
+ from seconds to minutes, then you collect. This package wraps that loop —
5
+ submit, poll with backoff, stream the result to disk — so callers write one
6
+ line instead of a retry loop.
7
+
8
+ Chat and image edits are OpenAI-compatible; use the official ``openai``
9
+ package against ``https://genrelay.ai/v1`` for those.
10
+ """
11
+
12
+ from .errors import (
13
+ APIError,
14
+ AuthenticationError,
15
+ GenRelayError,
16
+ InsufficientCreditsError,
17
+ JobFailedError,
18
+ JobTimeout,
19
+ RateLimitError,
20
+ )
21
+ from .types import TERMINAL_STATUSES, Video, VideoError, VideoStatus
22
+
23
+ __version__ = "0.1.0"
24
+
25
+ from ._client import GenRelay, Videos # noqa: E402 (needs __version__ above)
26
+
27
+ __all__ = [
28
+ "GenRelay",
29
+ "Videos",
30
+ "Video",
31
+ "VideoError",
32
+ "VideoStatus",
33
+ "TERMINAL_STATUSES",
34
+ "GenRelayError",
35
+ "APIError",
36
+ "AuthenticationError",
37
+ "InsufficientCreditsError",
38
+ "RateLimitError",
39
+ "JobFailedError",
40
+ "JobTimeout",
41
+ "__version__",
42
+ ]
@@ -0,0 +1,383 @@
1
+ """HTTP client for the GenRelay generation API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import random
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any, Callable, Iterator, Mapping, Sequence
10
+
11
+ import httpx
12
+
13
+ from .errors import (
14
+ APIError,
15
+ AuthenticationError,
16
+ InsufficientCreditsError,
17
+ JobFailedError,
18
+ JobTimeout,
19
+ RateLimitError,
20
+ )
21
+ from .types import Video
22
+
23
+ __all__ = ["GenRelay", "Videos"]
24
+
25
+ DEFAULT_BASE_URL = "https://genrelay.ai/v1"
26
+
27
+ # Generation is slow by nature — a 1080p Veo clip regularly takes minutes.
28
+ # These bound the *HTTP* calls, not the job: a job outliving the request is
29
+ # normal and is what polling is for.
30
+ DEFAULT_TIMEOUT = 60.0
31
+ DEFAULT_POLL_INTERVAL = 3.0
32
+ DEFAULT_MAX_WAIT = 900.0
33
+
34
+
35
+ def _user_agent(version: str) -> str:
36
+ return f"genrelay-python/{version}"
37
+
38
+
39
+ class Videos:
40
+ """Generation jobs — video *and* image (the API models both as "videos")."""
41
+
42
+ def __init__(self, client: "GenRelay") -> None:
43
+ self._client = client
44
+
45
+ # ---------------------------------------------------------------- create
46
+
47
+ def create(
48
+ self,
49
+ *,
50
+ model: str,
51
+ prompt: str,
52
+ seconds: int | str | None = None,
53
+ size: str | None = None,
54
+ tier: str | None = None,
55
+ reference_images: Sequence[str] | None = None,
56
+ metadata: Mapping[str, Any] | None = None,
57
+ **extra: Any,
58
+ ) -> Video:
59
+ """Submit a job and return immediately, without waiting for the result.
60
+
61
+ Args:
62
+ model: Public model name, e.g. ``"veo_3_1"`` or ``"gpt-image-2"``.
63
+ prompt: What to generate.
64
+ seconds: Clip length, 1–60 (default 4). Ignored by image models.
65
+ size: Pixel size as ``"WxH"``, e.g. ``"1280x720"``. The aspect
66
+ ratio is inferred from it — there is no separate parameter.
67
+ tier: Resolution/quality tier — ``1k``/``2k``/``4k`` for images,
68
+ ``720p``/``1080p``/``4k`` for Veo video. **Worth setting
69
+ explicitly**: pricing is per tier, and a size that matches no
70
+ tier falls back to the model's base rate.
71
+ reference_images: HTTPS URLs or ``data:`` URLs. Per-model limits
72
+ apply; see the model's page.
73
+ metadata: Model-specific extras that are not top-level fields,
74
+ e.g. ``{"first_last_frame": True}`` for omni.
75
+ **extra: Passed through verbatim, so a newly added API field is
76
+ usable before this client knows about it.
77
+
78
+ Returns:
79
+ The job in its initial state — usually ``queued``.
80
+ """
81
+ body: dict[str, Any] = {"model": model, "prompt": prompt}
82
+ if seconds is not None:
83
+ body["seconds"] = seconds
84
+ if size is not None:
85
+ body["size"] = size
86
+ if tier is not None:
87
+ body["tier"] = tier
88
+ if reference_images:
89
+ body["reference_images"] = list(reference_images)
90
+ if metadata:
91
+ body["metadata"] = dict(metadata)
92
+ body.update(extra)
93
+
94
+ return Video._from_json(self._client._request("POST", "/videos", json=body))
95
+
96
+ # -------------------------------------------------------------- retrieve
97
+
98
+ def retrieve(self, task_id: str) -> Video:
99
+ """Read the job's current state. One HTTP call, no waiting."""
100
+ return Video._from_json(self._client._request("GET", f"/videos/{task_id}"))
101
+
102
+ # ------------------------------------------------------------------ wait
103
+
104
+ def wait(
105
+ self,
106
+ task_id: str,
107
+ *,
108
+ timeout: float = DEFAULT_MAX_WAIT,
109
+ poll_interval: float = DEFAULT_POLL_INTERVAL,
110
+ on_progress: Callable[[Video], None] | None = None,
111
+ ) -> Video:
112
+ """Poll until the job reaches a terminal state.
113
+
114
+ Raises:
115
+ JobFailedError: the job finished as ``failed``.
116
+ JobTimeout: still running when ``timeout`` elapsed. The job is
117
+ *not* cancelled — poll ``task_id`` again later rather than
118
+ resubmitting, which would bill a second time.
119
+ """
120
+ deadline = time.monotonic() + timeout
121
+ attempt = 0
122
+
123
+ while True:
124
+ video = self.retrieve(task_id)
125
+ if on_progress is not None:
126
+ on_progress(video)
127
+ if video.is_done:
128
+ if video.status == "failed":
129
+ detail = video.error.message if video.error else "no detail given"
130
+ raise JobFailedError(
131
+ f"job {video.id} failed: {detail}", video=video
132
+ )
133
+ return video
134
+
135
+ remaining = deadline - time.monotonic()
136
+ if remaining <= 0:
137
+ raise JobTimeout(
138
+ f"job {task_id} still {video.status} after {timeout:g}s; "
139
+ f"poll it again later instead of resubmitting",
140
+ task_id=task_id,
141
+ )
142
+
143
+ # Small jitter so a batch of jobs started together does not poll in
144
+ # lockstep. Capped at 10s: generation is slow, but people watch
145
+ # progress bars.
146
+ delay = min(poll_interval * (1 + 0.25 * attempt), 10.0)
147
+ delay += random.uniform(0, 0.4)
148
+ time.sleep(min(delay, max(remaining, 0.1)))
149
+ attempt += 1
150
+
151
+ # -------------------------------------------------------------- generate
152
+
153
+ def generate(
154
+ self,
155
+ *,
156
+ model: str,
157
+ prompt: str,
158
+ timeout: float = DEFAULT_MAX_WAIT,
159
+ poll_interval: float = DEFAULT_POLL_INTERVAL,
160
+ on_progress: Callable[[Video], None] | None = None,
161
+ **params: Any,
162
+ ) -> Video:
163
+ """Submit and wait — the one-liner for scripts and notebooks.
164
+
165
+ Equivalent to :meth:`create` followed by :meth:`wait`, and takes the
166
+ same generation parameters as :meth:`create`.
167
+ """
168
+ job = self.create(model=model, prompt=prompt, **params)
169
+ return self.wait(
170
+ job.id,
171
+ timeout=timeout,
172
+ poll_interval=poll_interval,
173
+ on_progress=on_progress,
174
+ )
175
+
176
+ # -------------------------------------------------------------- download
177
+
178
+ def download(self, task_id: str, dest: str | Path) -> Path:
179
+ """Stream the finished asset to ``dest`` and return the path.
180
+
181
+ Streams rather than buffering: a minute of 1080p is tens of megabytes
182
+ and there is no reason to hold it in memory.
183
+ """
184
+ path = Path(dest)
185
+ path.parent.mkdir(parents=True, exist_ok=True)
186
+ # Write to a sibling temp file first so an interrupted download never
187
+ # leaves a half-written file that looks complete.
188
+ tmp = path.with_suffix(path.suffix + ".part")
189
+ with self._client._stream("GET", f"/videos/{task_id}/content") as response:
190
+ with tmp.open("wb") as fh:
191
+ for chunk in response.iter_bytes(chunk_size=1 << 16):
192
+ fh.write(chunk)
193
+ tmp.replace(path)
194
+ return path
195
+
196
+
197
+ class GenRelay:
198
+ """Client for the GenRelay generation API.
199
+
200
+ Usage::
201
+
202
+ from genrelay import GenRelay
203
+
204
+ client = GenRelay() # reads GENRELAY_API_KEY
205
+ video = client.videos.generate(
206
+ model="veo_3_1",
207
+ prompt="a neon fox running through a rainy city at night",
208
+ seconds=8,
209
+ size="1280x720",
210
+ tier="1080p",
211
+ )
212
+ client.videos.download(video.id, "fox.mp4")
213
+
214
+ Chat and image-edit endpoints are OpenAI-compatible — point the official
215
+ ``openai`` package at ``https://genrelay.ai/v1`` for those. This client
216
+ exists for the generation jobs, which are asynchronous and have no
217
+ equivalent in that SDK.
218
+ """
219
+
220
+ def __init__(
221
+ self,
222
+ api_key: str | None = None,
223
+ *,
224
+ base_url: str | None = None,
225
+ timeout: float = DEFAULT_TIMEOUT,
226
+ max_retries: int = 2,
227
+ http_client: httpx.Client | None = None,
228
+ ) -> None:
229
+ key = api_key or os.environ.get("GENRELAY_API_KEY", "")
230
+ if not key:
231
+ raise AuthenticationError(
232
+ "no API key: pass api_key=... or set GENRELAY_API_KEY"
233
+ )
234
+ self.api_key = key
235
+ self.base_url = (base_url or os.environ.get("GENRELAY_BASE_URL")
236
+ or DEFAULT_BASE_URL).rstrip("/")
237
+ self.max_retries = max(0, max_retries)
238
+
239
+ from . import __version__
240
+
241
+ self._owns_client = http_client is None
242
+ self._http = http_client or httpx.Client(timeout=timeout)
243
+ self._headers = {
244
+ "Authorization": f"Bearer {self.api_key}",
245
+ "User-Agent": _user_agent(__version__),
246
+ }
247
+
248
+ self.videos = Videos(self)
249
+
250
+ # ------------------------------------------------------------- lifecycle
251
+
252
+ def close(self) -> None:
253
+ if self._owns_client:
254
+ self._http.close()
255
+
256
+ def __enter__(self) -> "GenRelay":
257
+ return self
258
+
259
+ def __exit__(self, *_exc: object) -> None:
260
+ self.close()
261
+
262
+ # --------------------------------------------------------------- request
263
+
264
+ def _url(self, path: str) -> str:
265
+ return f"{self.base_url}/{path.lstrip('/')}"
266
+
267
+ def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
268
+ last_error: Exception | None = None
269
+
270
+ for attempt in range(self.max_retries + 1):
271
+ try:
272
+ response = self._http.request(
273
+ method, self._url(path), headers=self._headers, **kwargs
274
+ )
275
+ except httpx.TimeoutException as exc:
276
+ last_error = exc
277
+ if attempt < self.max_retries:
278
+ time.sleep(_backoff(attempt))
279
+ continue
280
+ raise APIError(f"request to {path} timed out") from exc
281
+ except httpx.HTTPError as exc:
282
+ raise APIError(f"request to {path} failed: {exc}") from exc
283
+
284
+ if response.status_code < 400:
285
+ return _decode(response, path)
286
+
287
+ # 429 and 5xx are worth retrying; 4xx means the request itself is
288
+ # wrong and retrying would only bill or annoy.
289
+ if response.status_code in (429, 500, 502, 503, 504) and attempt < self.max_retries:
290
+ time.sleep(_retry_after(response) or _backoff(attempt))
291
+ continue
292
+
293
+ raise _error_for(response, path)
294
+
295
+ raise APIError(f"request to {path} failed: {last_error}")
296
+
297
+ def _stream(self, method: str, path: str, **kwargs: Any):
298
+ return _StreamContext(self, method, path, kwargs)
299
+
300
+
301
+ class _StreamContext:
302
+ """Context manager that surfaces API errors before the body is streamed."""
303
+
304
+ def __init__(self, client: GenRelay, method: str, path: str, kwargs: dict[str, Any]):
305
+ self._ctx = client._http.stream(
306
+ method, client._url(path), headers=client._headers, **kwargs
307
+ )
308
+ self._path = path
309
+
310
+ def __enter__(self) -> httpx.Response:
311
+ response = self._ctx.__enter__()
312
+ if response.status_code >= 400:
313
+ response.read() # error bodies are small; read before classifying
314
+ raise _error_for(response, self._path)
315
+ return response
316
+
317
+ def __exit__(self, *exc: Any) -> None:
318
+ self._ctx.__exit__(*exc)
319
+
320
+
321
+ # ------------------------------------------------------------------ helpers
322
+
323
+
324
+ def _backoff(attempt: int) -> float:
325
+ return min(0.5 * (2**attempt), 8.0) + random.uniform(0, 0.3)
326
+
327
+
328
+ def _retry_after(response: httpx.Response) -> float | None:
329
+ raw = response.headers.get("Retry-After")
330
+ if not raw:
331
+ return None
332
+ try:
333
+ return max(0.0, float(raw))
334
+ except ValueError:
335
+ return None
336
+
337
+
338
+ def _decode(response: httpx.Response, path: str) -> dict[str, Any]:
339
+ try:
340
+ data = response.json()
341
+ except ValueError as exc:
342
+ raise APIError(
343
+ f"{path} returned non-JSON body", status=response.status_code
344
+ ) from exc
345
+ if not isinstance(data, dict):
346
+ raise APIError(f"{path} returned {type(data).__name__}, expected an object",
347
+ status=response.status_code, body=data)
348
+ return data
349
+
350
+
351
+ def _error_for(response: httpx.Response, path: str) -> APIError:
352
+ status = response.status_code
353
+ body: Any
354
+ try:
355
+ body = response.json()
356
+ except ValueError:
357
+ body = response.text
358
+
359
+ message, code = _extract_error(body)
360
+ message = message or f"{path} failed with HTTP {status}"
361
+
362
+ cls = {
363
+ 401: AuthenticationError,
364
+ 403: AuthenticationError,
365
+ 402: InsufficientCreditsError,
366
+ 429: RateLimitError,
367
+ }.get(status, APIError)
368
+ return cls(message, status=status, code=code, body=body)
369
+
370
+
371
+ def _extract_error(body: Any) -> tuple[str, str | None]:
372
+ """Pull (message, code) out of the several error shapes the API can emit."""
373
+ if not isinstance(body, dict):
374
+ return (str(body)[:300] if body else ""), None
375
+
376
+ error = body.get("error")
377
+ if isinstance(error, dict):
378
+ return str(error.get("message") or ""), (error.get("code") or None)
379
+ if isinstance(error, str) and error:
380
+ return error, None
381
+ if body.get("message"):
382
+ return str(body["message"]), (body.get("code") or None)
383
+ return "", None
@@ -0,0 +1,87 @@
1
+ """Exceptions raised by the GenRelay client.
2
+
3
+ Every failure mode gets its own class so callers can react without parsing
4
+ strings: a 402 means "top up", a 429 means "back off", a failed job means
5
+ "the prompt or the model choice is the problem". Catching ``GenRelayError``
6
+ catches all of them.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+
14
+ class GenRelayError(Exception):
15
+ """Base class for everything this library raises."""
16
+
17
+
18
+ class APIError(GenRelayError):
19
+ """The API returned a non-2xx response.
20
+
21
+ ``status`` is the HTTP status, ``code`` the machine-readable error code
22
+ when the response carried one, and ``body`` the decoded payload (or the
23
+ raw text when it was not JSON).
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ message: str,
29
+ *,
30
+ status: int | None = None,
31
+ code: str | None = None,
32
+ body: Any = None,
33
+ ) -> None:
34
+ super().__init__(message)
35
+ self.status = status
36
+ self.code = code
37
+ self.body = body
38
+
39
+ def __str__(self) -> str: # pragma: no cover - trivial
40
+ parts = [super().__str__()]
41
+ if self.status is not None:
42
+ parts.append(f"(HTTP {self.status}")
43
+ parts[-1] += f", code={self.code})" if self.code else ")"
44
+ return " ".join(parts)
45
+
46
+
47
+ class AuthenticationError(APIError):
48
+ """401 — the API key is missing, malformed, or revoked."""
49
+
50
+
51
+ class InsufficientCreditsError(APIError):
52
+ """402 — the account is out of credits.
53
+
54
+ Generation is pay-per-call, so this is an ordinary, expected outcome
55
+ rather than a bug. Surface it to the user and let them top up.
56
+ """
57
+
58
+
59
+ class RateLimitError(APIError):
60
+ """429 — too many requests. Retry after a delay."""
61
+
62
+
63
+ class JobFailedError(GenRelayError):
64
+ """The job was accepted but finished with ``status == "failed"``.
65
+
66
+ This is not a transport error: the request was valid and billed-or-not per
67
+ platform rules, but the upstream model could not produce a result. The
68
+ failed :class:`~genrelay.types.Video` is attached as ``video`` so callers
69
+ can inspect ``video.error`` and ``video.model``.
70
+ """
71
+
72
+ def __init__(self, message: str, *, video: Any = None) -> None:
73
+ super().__init__(message)
74
+ self.video = video
75
+
76
+
77
+ class JobTimeout(GenRelayError):
78
+ """The job did not reach a terminal state within ``timeout`` seconds.
79
+
80
+ The job itself keeps running server-side — the task id is attached as
81
+ ``task_id`` so it can be polled again later instead of being resubmitted
82
+ (resubmitting bills a second time).
83
+ """
84
+
85
+ def __init__(self, message: str, *, task_id: str | None = None) -> None:
86
+ super().__init__(message)
87
+ self.task_id = task_id
File without changes
@@ -0,0 +1,86 @@
1
+ """Typed views over the API's JSON payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Literal
7
+
8
+ VideoStatus = Literal["queued", "in_progress", "completed", "failed"]
9
+
10
+ #: Statuses the server never moves away from. Polling stops here.
11
+ TERMINAL_STATUSES: frozenset[str] = frozenset({"completed", "failed"})
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class VideoError:
16
+ """The ``error`` object present on failed jobs."""
17
+
18
+ message: str = ""
19
+ code: str = ""
20
+
21
+ @classmethod
22
+ def _from_json(cls, data: Any) -> "VideoError | None":
23
+ if not isinstance(data, dict):
24
+ return None
25
+ return cls(
26
+ message=str(data.get("message") or ""),
27
+ code=str(data.get("code") or ""),
28
+ )
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Video:
33
+ """One generation job.
34
+
35
+ The same shape is returned by create and by every poll, so a ``Video`` is
36
+ a snapshot rather than a live handle — re-read it to see progress move.
37
+
38
+ Despite the name the API uses ``object == "video"`` for image jobs too;
39
+ that is the platform's convention, not a bug in this client.
40
+ """
41
+
42
+ id: str
43
+ status: VideoStatus
44
+ model: str = ""
45
+ progress: int = 0
46
+ created_at: int = 0
47
+ completed_at: int | None = None
48
+ expires_at: int | None = None
49
+ output_url: str | None = None
50
+ error: VideoError | None = None
51
+ #: The untouched response body. Anything this dataclass does not model yet
52
+ #: (new fields, model-specific extras) stays reachable here.
53
+ raw: dict[str, Any] = field(default_factory=dict, repr=False)
54
+
55
+ @property
56
+ def is_done(self) -> bool:
57
+ """True once the server will not change this job any further."""
58
+ return self.status in TERMINAL_STATUSES
59
+
60
+ @property
61
+ def succeeded(self) -> bool:
62
+ return self.status == "completed"
63
+
64
+ @classmethod
65
+ def _from_json(cls, data: dict[str, Any]) -> "Video":
66
+ # `id` is the documented field; `task_id` is the deprecated alias kept
67
+ # for older integrations. Prefer `id`, fall back so we keep working if
68
+ # a response only carries the legacy name.
69
+ ident = data.get("id") or data.get("task_id") or ""
70
+
71
+ def _opt_int(key: str) -> int | None:
72
+ value = data.get(key)
73
+ return int(value) if isinstance(value, (int, float)) else None
74
+
75
+ return cls(
76
+ id=str(ident),
77
+ status=str(data.get("status") or "queued"), # type: ignore[arg-type]
78
+ model=str(data.get("model") or ""),
79
+ progress=int(data.get("progress") or 0),
80
+ created_at=int(data.get("created_at") or 0),
81
+ completed_at=_opt_int("completed_at"),
82
+ expires_at=_opt_int("expires_at"),
83
+ output_url=data.get("output_url") or None,
84
+ error=VideoError._from_json(data.get("error")),
85
+ raw=data,
86
+ )
@@ -0,0 +1,264 @@
1
+ """Tests for the generation client.
2
+
3
+ Everything runs against an httpx mock transport — no network, no API key, no
4
+ credits burned. The point is to pin the behaviour that is easy to get wrong:
5
+ polling stops at terminal states, failures raise instead of returning a
6
+ half-empty object, and HTTP status codes map to the right exception.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+
13
+ import httpx
14
+ import pytest
15
+
16
+ from genrelay import (
17
+ APIError,
18
+ AuthenticationError,
19
+ GenRelay,
20
+ InsufficientCreditsError,
21
+ JobFailedError,
22
+ JobTimeout,
23
+ RateLimitError,
24
+ )
25
+
26
+
27
+ def client_with(handler) -> GenRelay:
28
+ transport = httpx.MockTransport(handler)
29
+ return GenRelay(
30
+ api_key="sk-test",
31
+ http_client=httpx.Client(transport=transport),
32
+ max_retries=0,
33
+ )
34
+
35
+
36
+ def job(status: str, **extra) -> dict:
37
+ body = {
38
+ "id": "task_abc",
39
+ "task_id": "task_abc",
40
+ "object": "video",
41
+ "model": "veo_3_1",
42
+ "status": status,
43
+ "progress": 100 if status == "completed" else 10,
44
+ "created_at": 1790000000,
45
+ }
46
+ body.update(extra)
47
+ return body
48
+
49
+
50
+ # ------------------------------------------------------------------- create
51
+
52
+
53
+ def test_create_sends_only_the_fields_given():
54
+ seen = {}
55
+
56
+ def handler(request: httpx.Request) -> httpx.Response:
57
+ seen.update(json.loads(request.content))
58
+ return httpx.Response(200, json=job("queued"))
59
+
60
+ c = client_with(handler)
61
+ c.videos.create(model="veo_3_1", prompt="a fox", seconds=8, tier="1080p")
62
+
63
+ # Optional params the caller omitted must not be sent at all: sending
64
+ # size=None would override a model default server-side.
65
+ assert seen == {"model": "veo_3_1", "prompt": "a fox", "seconds": 8, "tier": "1080p"}
66
+
67
+
68
+ def test_create_passes_unknown_fields_through():
69
+ seen = {}
70
+
71
+ def handler(request: httpx.Request) -> httpx.Response:
72
+ seen.update(json.loads(request.content))
73
+ return httpx.Response(200, json=job("queued"))
74
+
75
+ c = client_with(handler)
76
+ c.videos.create(model="m", prompt="p", some_new_api_field="x")
77
+ assert seen["some_new_api_field"] == "x"
78
+
79
+
80
+ def test_create_sends_auth_header():
81
+ seen = {}
82
+
83
+ def handler(request: httpx.Request) -> httpx.Response:
84
+ seen["auth"] = request.headers.get("Authorization")
85
+ seen["ua"] = request.headers.get("User-Agent")
86
+ return httpx.Response(200, json=job("queued"))
87
+
88
+ client_with(handler).videos.create(model="m", prompt="p")
89
+ assert seen["auth"] == "Bearer sk-test"
90
+ assert seen["ua"].startswith("genrelay-python/")
91
+
92
+
93
+ # --------------------------------------------------------------------- wait
94
+
95
+
96
+ def test_wait_polls_until_completed():
97
+ states = iter(["queued", "in_progress", "completed"])
98
+ calls = {"n": 0}
99
+
100
+ def handler(request: httpx.Request) -> httpx.Response:
101
+ calls["n"] += 1
102
+ return httpx.Response(200, json=job(next(states), output_url="https://x/v"))
103
+
104
+ c = client_with(handler)
105
+ video = c.videos.wait("task_abc", poll_interval=0.001)
106
+
107
+ assert calls["n"] == 3
108
+ assert video.succeeded and video.is_done
109
+ assert video.output_url == "https://x/v"
110
+
111
+
112
+ def test_wait_raises_on_failed_and_keeps_the_job():
113
+ def handler(request: httpx.Request) -> httpx.Response:
114
+ return httpx.Response(
115
+ 200,
116
+ json=job("failed", error={"message": "unsafe prompt", "code": "rejected"}),
117
+ )
118
+
119
+ c = client_with(handler)
120
+ with pytest.raises(JobFailedError) as excinfo:
121
+ c.videos.wait("task_abc", poll_interval=0.001)
122
+
123
+ # The failed job must be reachable — callers need the reason and the model.
124
+ assert excinfo.value.video.error.code == "rejected"
125
+ assert "unsafe prompt" in str(excinfo.value)
126
+
127
+
128
+ def test_wait_times_out_without_losing_the_task_id():
129
+ def handler(request: httpx.Request) -> httpx.Response:
130
+ return httpx.Response(200, json=job("in_progress"))
131
+
132
+ c = client_with(handler)
133
+ with pytest.raises(JobTimeout) as excinfo:
134
+ c.videos.wait("task_abc", timeout=0.05, poll_interval=0.001)
135
+
136
+ # Losing the id would force a resubmit, which bills a second time.
137
+ assert excinfo.value.task_id == "task_abc"
138
+
139
+
140
+ def test_on_progress_sees_every_poll():
141
+ states = iter(["queued", "in_progress", "completed"])
142
+
143
+ def handler(request: httpx.Request) -> httpx.Response:
144
+ return httpx.Response(200, json=job(next(states)))
145
+
146
+ seen = []
147
+ c = client_with(handler)
148
+ c.videos.wait("task_abc", poll_interval=0.001, on_progress=seen.append)
149
+ assert [v.status for v in seen] == ["queued", "in_progress", "completed"]
150
+
151
+
152
+ # ----------------------------------------------------------------- generate
153
+
154
+
155
+ def test_generate_creates_then_waits():
156
+ seen_paths = []
157
+ states = iter(["queued", "completed"])
158
+
159
+ def handler(request: httpx.Request) -> httpx.Response:
160
+ seen_paths.append((request.method, request.url.path))
161
+ if request.method == "POST":
162
+ return httpx.Response(200, json=job("queued"))
163
+ return httpx.Response(200, json=job(next(states)))
164
+
165
+ c = client_with(handler)
166
+ video = c.videos.generate(model="veo_3_1", prompt="p", poll_interval=0.001)
167
+
168
+ assert seen_paths[0] == ("POST", "/v1/videos")
169
+ assert all(m == "GET" for m, _ in seen_paths[1:])
170
+ assert video.succeeded
171
+
172
+
173
+ # ------------------------------------------------------------------- errors
174
+
175
+
176
+ @pytest.mark.parametrize(
177
+ "status,expected",
178
+ [
179
+ (401, AuthenticationError),
180
+ (403, AuthenticationError),
181
+ (402, InsufficientCreditsError),
182
+ (429, RateLimitError),
183
+ (400, APIError),
184
+ (500, APIError),
185
+ ],
186
+ )
187
+ def test_status_codes_map_to_exceptions(status, expected):
188
+ def handler(request: httpx.Request) -> httpx.Response:
189
+ return httpx.Response(status, json={"error": {"message": "nope", "code": "e"}})
190
+
191
+ c = client_with(handler)
192
+ with pytest.raises(expected) as excinfo:
193
+ c.videos.create(model="m", prompt="p")
194
+ assert excinfo.value.status == status
195
+ assert excinfo.value.code == "e"
196
+
197
+
198
+ def test_error_message_survives_a_plain_string_body():
199
+ def handler(request: httpx.Request) -> httpx.Response:
200
+ return httpx.Response(500, text="upstream exploded")
201
+
202
+ c = client_with(handler)
203
+ with pytest.raises(APIError) as excinfo:
204
+ c.videos.create(model="m", prompt="p")
205
+ assert "upstream exploded" in str(excinfo.value)
206
+
207
+
208
+ def test_missing_api_key_fails_fast(monkeypatch):
209
+ monkeypatch.delenv("GENRELAY_API_KEY", raising=False)
210
+ with pytest.raises(AuthenticationError):
211
+ GenRelay()
212
+
213
+
214
+ def test_api_key_from_environment(monkeypatch):
215
+ monkeypatch.setenv("GENRELAY_API_KEY", "sk-env")
216
+ assert GenRelay().api_key == "sk-env"
217
+
218
+
219
+ # ----------------------------------------------------------------- download
220
+
221
+
222
+ def test_download_streams_to_disk(tmp_path):
223
+ def handler(request: httpx.Request) -> httpx.Response:
224
+ assert request.url.path == "/v1/videos/task_abc/content"
225
+ return httpx.Response(200, content=b"\x00\x01binary")
226
+
227
+ c = client_with(handler)
228
+ out = c.videos.download("task_abc", tmp_path / "nested" / "clip.mp4")
229
+
230
+ assert out.read_bytes() == b"\x00\x01binary"
231
+ # The .part file must not survive a successful download.
232
+ assert not (tmp_path / "nested" / "clip.mp4.part").exists()
233
+
234
+
235
+ def test_download_raises_before_writing_anything(tmp_path):
236
+ def handler(request: httpx.Request) -> httpx.Response:
237
+ return httpx.Response(404, json={"error": {"message": "gone"}})
238
+
239
+ c = client_with(handler)
240
+ dest = tmp_path / "clip.mp4"
241
+ with pytest.raises(APIError):
242
+ c.videos.download("task_abc", dest)
243
+ assert not dest.exists()
244
+
245
+
246
+ # -------------------------------------------------------------------- types
247
+
248
+
249
+ def test_legacy_task_id_is_accepted_when_id_is_absent():
250
+ def handler(request: httpx.Request) -> httpx.Response:
251
+ body = job("completed")
252
+ del body["id"] # older responses only carried task_id
253
+ return httpx.Response(200, json=body)
254
+
255
+ c = client_with(handler)
256
+ assert c.videos.retrieve("task_abc").id == "task_abc"
257
+
258
+
259
+ def test_raw_keeps_unmodelled_fields():
260
+ def handler(request: httpx.Request) -> httpx.Response:
261
+ return httpx.Response(200, json=job("completed", future_field={"a": 1}))
262
+
263
+ c = client_with(handler)
264
+ assert c.videos.retrieve("task_abc").raw["future_field"] == {"a": 1}