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.
- genrelay-0.1.0/.gitignore +12 -0
- genrelay-0.1.0/LICENSE +21 -0
- genrelay-0.1.0/PKG-INFO +191 -0
- genrelay-0.1.0/README.md +138 -0
- genrelay-0.1.0/pyproject.toml +68 -0
- genrelay-0.1.0/src/genrelay/__init__.py +42 -0
- genrelay-0.1.0/src/genrelay/_client.py +383 -0
- genrelay-0.1.0/src/genrelay/errors.py +87 -0
- genrelay-0.1.0/src/genrelay/py.typed +0 -0
- genrelay-0.1.0/src/genrelay/types.py +86 -0
- genrelay-0.1.0/tests/test_videos.py +264 -0
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.
|
genrelay-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
genrelay-0.1.0/README.md
ADDED
|
@@ -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}
|