tanvo 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.
- tanvo-0.1.0/.github/workflows/publish.yml +20 -0
- tanvo-0.1.0/.github/workflows/test.yml +14 -0
- tanvo-0.1.0/.gitignore +6 -0
- tanvo-0.1.0/LICENSE +21 -0
- tanvo-0.1.0/PKG-INFO +159 -0
- tanvo-0.1.0/README.md +136 -0
- tanvo-0.1.0/examples/pet_portrait.py +11 -0
- tanvo-0.1.0/examples/quickstart.py +7 -0
- tanvo-0.1.0/pyproject.toml +34 -0
- tanvo-0.1.0/src/tanvo/__init__.py +12 -0
- tanvo-0.1.0/src/tanvo/_client.py +266 -0
- tanvo-0.1.0/src/tanvo/_errors.py +56 -0
- tanvo-0.1.0/src/tanvo/_types.py +148 -0
- tanvo-0.1.0/src/tanvo/_webhook.py +43 -0
- tanvo-0.1.0/src/tanvo/py.typed +0 -0
- tanvo-0.1.0/tests/test_client.py +328 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Publishes to PyPI when a version tag (v0.1.0, …) is pushed. Uses PyPI trusted publishing: no token is stored here.
|
|
2
|
+
# One-time setup on pypi.org: add a trusted publisher for this repository, workflow "publish.yml", environment "pypi".
|
|
3
|
+
name: publish
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
tags: ["v*"]
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
environment: pypi
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.13"
|
|
18
|
+
- run: python -m unittest discover -s tests
|
|
19
|
+
- run: python -m pip install build && python -m build
|
|
20
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
name: test
|
|
2
|
+
on: [push, pull_request]
|
|
3
|
+
jobs:
|
|
4
|
+
test:
|
|
5
|
+
runs-on: ubuntu-latest
|
|
6
|
+
strategy:
|
|
7
|
+
matrix:
|
|
8
|
+
python: ["3.9", "3.11", "3.13"]
|
|
9
|
+
steps:
|
|
10
|
+
- uses: actions/checkout@v4
|
|
11
|
+
- uses: actions/setup-python@v5
|
|
12
|
+
with:
|
|
13
|
+
python-version: ${{ matrix.python }}
|
|
14
|
+
- run: python -m unittest discover -s tests -v
|
tanvo-0.1.0/.gitignore
ADDED
tanvo-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tanvo
|
|
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.
|
tanvo-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tanvo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for Tanvo: AI images, video and songs on Nano Banana, Seedream, GPT Image, Veo, Kling, Seedance and Suno, plus 80 ready-made photo apps. No dependencies; the house image engine works without an API key.
|
|
5
|
+
Project-URL: Homepage, https://tanvo.ai/?utm_source=pypi&utm_medium=referral
|
|
6
|
+
Project-URL: Documentation, https://github.com/tanvoai/tanvo-python#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/tanvoai/tanvo-python
|
|
8
|
+
Project-URL: Issues, https://github.com/tanvoai/tanvo-python/issues
|
|
9
|
+
Author-email: Tanvo <support@tanvo.ai>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai image generator,ai music,ai video generator,api,image to video,kling,nano banana,seedance,seedream,suno,tanvo,text to image,text to video,veo
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
18
|
+
Classifier: Topic :: Multimedia :: Video
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# Tanvo for Python
|
|
25
|
+
|
|
26
|
+
[](https://pypi.org/project/tanvo/) [](LICENSE)
|
|
27
|
+
|
|
28
|
+
The Python client for [Tanvo](https://tanvo.ai/?utm_source=github&utm_medium=referral), an AI image, video and music studio. Generate images on Nano Banana, Seedream, GPT Image and Qwen, video on Veo, Kling, Seedance and Wan, and songs on Suno, or run one of 80 ready-made photo apps (pet portraits, figurines, old photo restoration, trend videos) on your own photos.
|
|
29
|
+
|
|
30
|
+
- No dependencies: only the standard library. Python 3.9+.
|
|
31
|
+
- **Works without an API key** on the free tier (house image engine, watermarked).
|
|
32
|
+
- Local files are uploaded for you; results download with one call.
|
|
33
|
+
- Retries that never double-charge (every submit carries an idempotency key).
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install tanvo
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import tanvo
|
|
43
|
+
|
|
44
|
+
client = tanvo.Client() # reads TANVO_API_KEY; without one it runs on the free tier
|
|
45
|
+
image = client.generate_image("a paper lantern floating over a misty lake at dawn", aspect="16:9")
|
|
46
|
+
print(image.url)
|
|
47
|
+
client.download(image, "out/")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Create a key under [Settings → API keys](https://tanvo.ai/settings/apikeys?utm_source=github&utm_medium=referral) to use every model, get clean output and have runs land in your history.
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
client = tanvo.Client("sk_...") # or export TANVO_API_KEY=sk_...
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Ready-made apps
|
|
57
|
+
|
|
58
|
+
Each app has preset looks: a tuned prompt, model and settings with a real example output.
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
for app in client.apps(kind="image")[:5]:
|
|
62
|
+
print(app.slug, "-", app.name, [look.id for look in app.looks])
|
|
63
|
+
|
|
64
|
+
portrait = client.generate_from_app(
|
|
65
|
+
"ai-pet-portrait-generator",
|
|
66
|
+
look="royal",
|
|
67
|
+
photos=["rex.jpg"], # local path, https URL or bytes, in the order of app.inputs
|
|
68
|
+
details="Rex, a very good boy",
|
|
69
|
+
)
|
|
70
|
+
client.download(portrait, "out/")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Images
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
# Text to image on any model (see client.models("image") for ids, options and prices)
|
|
77
|
+
img = client.generate_image("isometric cutaway of a tiny ramen shop", model="nano-banana-2", resolution="2K")
|
|
78
|
+
|
|
79
|
+
# Edit or combine photos: describe what to change and what to keep
|
|
80
|
+
edit = client.generate_image(
|
|
81
|
+
"change the jacket to dark green; keep the face, pose and background exactly as they are",
|
|
82
|
+
model="nano-banana-2",
|
|
83
|
+
images=["portrait.jpg"],
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Video
|
|
88
|
+
|
|
89
|
+
Video takes one to several minutes, so `generate_video` returns at once by default.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
job = client.generate_video(
|
|
93
|
+
"slow dolly-in on a lighthouse in a storm, waves crashing, cinematic",
|
|
94
|
+
model="kling-3-0", aspect="16:9", resolution="720p", duration=5,
|
|
95
|
+
)
|
|
96
|
+
clip = client.wait(job.id) # or pass wait=True above
|
|
97
|
+
client.download(clip, "out/")
|
|
98
|
+
|
|
99
|
+
# Image to video, with an optional end frame on models that support it
|
|
100
|
+
client.generate_video("the cat turns its head and blinks", model="seedance-2-5", image="cat.jpg", wait=True)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Songs
|
|
104
|
+
|
|
105
|
+
Two takes per run, each an MP3 with cover art, a title and the lyrics as sung.
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
song = client.generate_music(
|
|
109
|
+
"an upbeat birthday song for my sister Ana: surfing, terrible puns, a big singalong chorus",
|
|
110
|
+
style="acoustic pop, hand claps, warm female vocal",
|
|
111
|
+
)
|
|
112
|
+
for take in song.outputs:
|
|
113
|
+
print(take.title, take.seconds, take.url, take.cover)
|
|
114
|
+
|
|
115
|
+
client.generate_music("[Verse]\n...\n[Chorus]\n...", mode="Lyrics", title="Ana Rides Again", vocal="f")
|
|
116
|
+
client.generate_music("mellow lo-fi study beat, vinyl crackle, 75 bpm", mode="Instrumental")
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Runs, history and webhooks
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
g = client.get("cm...") # one run
|
|
123
|
+
items, next_before = client.list(limit=20)
|
|
124
|
+
|
|
125
|
+
# Skip polling: have the result POSTed to you when it finishes
|
|
126
|
+
client.generate_video("...", model="veo-3-1", webhook_url="https://example.com/hooks/tanvo")
|
|
127
|
+
|
|
128
|
+
# In your web handler, verify with the secret from Settings → Webhooks (pass the raw body)
|
|
129
|
+
generation = tanvo.verify_webhook(secret, request.headers, request.get_data())
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Webhooks can arrive more than once; de-duplicate on the `webhook-id` header.
|
|
133
|
+
|
|
134
|
+
## Errors
|
|
135
|
+
|
|
136
|
+
| Exception | When | What to do |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| `InvalidRequest` | Bad option, refused prompt, file too large | Fix the input |
|
|
139
|
+
| `AuthError` | Missing or invalid key | Check the key |
|
|
140
|
+
| `QuotaError` | Out of credits, free tier limit, `busy` | Top up, add a key, or wait (`wait_for_slot=True` waits for a free slot) |
|
|
141
|
+
| `ServiceError` | Upstream model or network failure | Already retried twice with the same request key; try again later |
|
|
142
|
+
|
|
143
|
+
All carry `.code` (stable string), `.message` and `.status`. Failed runs are refunded automatically.
|
|
144
|
+
|
|
145
|
+
## Settings
|
|
146
|
+
|
|
147
|
+
| | |
|
|
148
|
+
|---|---|
|
|
149
|
+
| `TANVO_API_KEY` | API key (or `Client(api_key=...)`) |
|
|
150
|
+
| `TANVO_BASE_URL` | Another deployment (default `https://tanvo.ai`) |
|
|
151
|
+
|
|
152
|
+
## Also from Tanvo
|
|
153
|
+
|
|
154
|
+
- [tanvo-mcp](https://github.com/tanvoai/tanvo-mcp): the same apps and models inside Claude, Cursor and other MCP clients.
|
|
155
|
+
- [tanvo-js](https://github.com/tanvoai/tanvo-js): the TypeScript / JavaScript client.
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT
|
tanvo-0.1.0/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Tanvo for Python
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/tanvo/) [](LICENSE)
|
|
4
|
+
|
|
5
|
+
The Python client for [Tanvo](https://tanvo.ai/?utm_source=github&utm_medium=referral), an AI image, video and music studio. Generate images on Nano Banana, Seedream, GPT Image and Qwen, video on Veo, Kling, Seedance and Wan, and songs on Suno, or run one of 80 ready-made photo apps (pet portraits, figurines, old photo restoration, trend videos) on your own photos.
|
|
6
|
+
|
|
7
|
+
- No dependencies: only the standard library. Python 3.9+.
|
|
8
|
+
- **Works without an API key** on the free tier (house image engine, watermarked).
|
|
9
|
+
- Local files are uploaded for you; results download with one call.
|
|
10
|
+
- Retries that never double-charge (every submit carries an idempotency key).
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install tanvo
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import tanvo
|
|
20
|
+
|
|
21
|
+
client = tanvo.Client() # reads TANVO_API_KEY; without one it runs on the free tier
|
|
22
|
+
image = client.generate_image("a paper lantern floating over a misty lake at dawn", aspect="16:9")
|
|
23
|
+
print(image.url)
|
|
24
|
+
client.download(image, "out/")
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Create a key under [Settings → API keys](https://tanvo.ai/settings/apikeys?utm_source=github&utm_medium=referral) to use every model, get clean output and have runs land in your history.
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
client = tanvo.Client("sk_...") # or export TANVO_API_KEY=sk_...
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Ready-made apps
|
|
34
|
+
|
|
35
|
+
Each app has preset looks: a tuned prompt, model and settings with a real example output.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
for app in client.apps(kind="image")[:5]:
|
|
39
|
+
print(app.slug, "-", app.name, [look.id for look in app.looks])
|
|
40
|
+
|
|
41
|
+
portrait = client.generate_from_app(
|
|
42
|
+
"ai-pet-portrait-generator",
|
|
43
|
+
look="royal",
|
|
44
|
+
photos=["rex.jpg"], # local path, https URL or bytes, in the order of app.inputs
|
|
45
|
+
details="Rex, a very good boy",
|
|
46
|
+
)
|
|
47
|
+
client.download(portrait, "out/")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Images
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
# Text to image on any model (see client.models("image") for ids, options and prices)
|
|
54
|
+
img = client.generate_image("isometric cutaway of a tiny ramen shop", model="nano-banana-2", resolution="2K")
|
|
55
|
+
|
|
56
|
+
# Edit or combine photos: describe what to change and what to keep
|
|
57
|
+
edit = client.generate_image(
|
|
58
|
+
"change the jacket to dark green; keep the face, pose and background exactly as they are",
|
|
59
|
+
model="nano-banana-2",
|
|
60
|
+
images=["portrait.jpg"],
|
|
61
|
+
)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Video
|
|
65
|
+
|
|
66
|
+
Video takes one to several minutes, so `generate_video` returns at once by default.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
job = client.generate_video(
|
|
70
|
+
"slow dolly-in on a lighthouse in a storm, waves crashing, cinematic",
|
|
71
|
+
model="kling-3-0", aspect="16:9", resolution="720p", duration=5,
|
|
72
|
+
)
|
|
73
|
+
clip = client.wait(job.id) # or pass wait=True above
|
|
74
|
+
client.download(clip, "out/")
|
|
75
|
+
|
|
76
|
+
# Image to video, with an optional end frame on models that support it
|
|
77
|
+
client.generate_video("the cat turns its head and blinks", model="seedance-2-5", image="cat.jpg", wait=True)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Songs
|
|
81
|
+
|
|
82
|
+
Two takes per run, each an MP3 with cover art, a title and the lyrics as sung.
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
song = client.generate_music(
|
|
86
|
+
"an upbeat birthday song for my sister Ana: surfing, terrible puns, a big singalong chorus",
|
|
87
|
+
style="acoustic pop, hand claps, warm female vocal",
|
|
88
|
+
)
|
|
89
|
+
for take in song.outputs:
|
|
90
|
+
print(take.title, take.seconds, take.url, take.cover)
|
|
91
|
+
|
|
92
|
+
client.generate_music("[Verse]\n...\n[Chorus]\n...", mode="Lyrics", title="Ana Rides Again", vocal="f")
|
|
93
|
+
client.generate_music("mellow lo-fi study beat, vinyl crackle, 75 bpm", mode="Instrumental")
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Runs, history and webhooks
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
g = client.get("cm...") # one run
|
|
100
|
+
items, next_before = client.list(limit=20)
|
|
101
|
+
|
|
102
|
+
# Skip polling: have the result POSTed to you when it finishes
|
|
103
|
+
client.generate_video("...", model="veo-3-1", webhook_url="https://example.com/hooks/tanvo")
|
|
104
|
+
|
|
105
|
+
# In your web handler, verify with the secret from Settings → Webhooks (pass the raw body)
|
|
106
|
+
generation = tanvo.verify_webhook(secret, request.headers, request.get_data())
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Webhooks can arrive more than once; de-duplicate on the `webhook-id` header.
|
|
110
|
+
|
|
111
|
+
## Errors
|
|
112
|
+
|
|
113
|
+
| Exception | When | What to do |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| `InvalidRequest` | Bad option, refused prompt, file too large | Fix the input |
|
|
116
|
+
| `AuthError` | Missing or invalid key | Check the key |
|
|
117
|
+
| `QuotaError` | Out of credits, free tier limit, `busy` | Top up, add a key, or wait (`wait_for_slot=True` waits for a free slot) |
|
|
118
|
+
| `ServiceError` | Upstream model or network failure | Already retried twice with the same request key; try again later |
|
|
119
|
+
|
|
120
|
+
All carry `.code` (stable string), `.message` and `.status`. Failed runs are refunded automatically.
|
|
121
|
+
|
|
122
|
+
## Settings
|
|
123
|
+
|
|
124
|
+
| | |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `TANVO_API_KEY` | API key (or `Client(api_key=...)`) |
|
|
127
|
+
| `TANVO_BASE_URL` | Another deployment (default `https://tanvo.ai`) |
|
|
128
|
+
|
|
129
|
+
## Also from Tanvo
|
|
130
|
+
|
|
131
|
+
- [tanvo-mcp](https://github.com/tanvoai/tanvo-mcp): the same apps and models inside Claude, Cursor and other MCP clients.
|
|
132
|
+
- [tanvo-js](https://github.com/tanvoai/tanvo-js): the TypeScript / JavaScript client.
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""python examples/pet_portrait.py rex.jpg: a royal portrait of your pet (needs TANVO_API_KEY)."""
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import tanvo
|
|
5
|
+
|
|
6
|
+
client = tanvo.Client()
|
|
7
|
+
app = client.app("ai-pet-portrait-generator")
|
|
8
|
+
print("Looks:", ", ".join(f"{l.id} ({l.name})" for l in app.looks))
|
|
9
|
+
result = client.generate_from_app(app.slug, look="royal", photos=[sys.argv[1]])
|
|
10
|
+
print(result.status, result.url)
|
|
11
|
+
print("saved to", client.download(result, "out/"))
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""python examples/quickstart.py: one free image, no API key needed."""
|
|
2
|
+
import tanvo
|
|
3
|
+
|
|
4
|
+
client = tanvo.Client()
|
|
5
|
+
image = client.generate_image("a paper lantern floating over a misty lake at dawn", aspect="16:9")
|
|
6
|
+
print(image.status, image.url)
|
|
7
|
+
print("saved to", client.download(image, "out/"))
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tanvo"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for Tanvo: AI images, video and songs on Nano Banana, Seedream, GPT Image, Veo, Kling, Seedance and Suno, plus 80 ready-made photo apps. No dependencies; the house image engine works without an API key."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
authors = [{ name = "Tanvo", email = "support@tanvo.ai" }]
|
|
14
|
+
keywords = ["tanvo", "ai image generator", "ai video generator", "text to image", "image to video", "text to video", "ai music", "nano banana", "seedream", "kling", "veo", "seedance", "suno", "api"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Multimedia :: Graphics",
|
|
21
|
+
"Topic :: Multimedia :: Video",
|
|
22
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = []
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://tanvo.ai/?utm_source=pypi&utm_medium=referral"
|
|
29
|
+
Documentation = "https://github.com/tanvoai/tanvo-python#readme"
|
|
30
|
+
Repository = "https://github.com/tanvoai/tanvo-python"
|
|
31
|
+
Issues = "https://github.com/tanvoai/tanvo-python/issues"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/tanvo"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Tanvo: AI images, video and songs from Python. https://tanvo.ai"""
|
|
2
|
+
|
|
3
|
+
from ._client import DEFAULT_BASE_URL, Client, __version__
|
|
4
|
+
from ._errors import AuthError, InvalidRequest, QuotaError, ServiceError, TanvoError, WebhookVerificationError
|
|
5
|
+
from ._types import Account, App, AppInput, Generation, Look, Model, Output
|
|
6
|
+
from ._webhook import verify_webhook
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Client", "DEFAULT_BASE_URL", "__version__", "verify_webhook",
|
|
10
|
+
"TanvoError", "InvalidRequest", "AuthError", "QuotaError", "ServiceError", "WebhookVerificationError",
|
|
11
|
+
"Account", "App", "AppInput", "Generation", "Look", "Model", "Output",
|
|
12
|
+
]
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import mimetypes
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
|
13
|
+
|
|
14
|
+
from ._errors import ServiceError, TanvoError, error_for
|
|
15
|
+
from ._types import Account, App, Generation, Model
|
|
16
|
+
from ._webhook import verify_webhook
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
DEFAULT_BASE_URL = "https://tanvo.ai"
|
|
21
|
+
ImageInput = Union[str, os.PathLike, bytes]
|
|
22
|
+
_MIME = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".mp4": "video/mp4", ".mov": "video/quicktime", ".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Client:
|
|
26
|
+
"""Tanvo API client.
|
|
27
|
+
|
|
28
|
+
With no key (argument or TANVO_API_KEY) it runs on the anonymous free tier: the house image engine only,
|
|
29
|
+
watermarked, about one image per client. Create a key at https://tanvo.ai/settings/apikeys for everything else.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, api_key: Optional[str] = None, *, anon_id: Optional[str] = None, base_url: Optional[str] = None, timeout: float = 60.0):
|
|
33
|
+
self.api_key = api_key or os.environ.get("TANVO_API_KEY") or None
|
|
34
|
+
self.anon_id = None if self.api_key else (anon_id or f"py-{uuid.uuid4()}")
|
|
35
|
+
self.base_url = (base_url or os.environ.get("TANVO_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
36
|
+
self.timeout = timeout
|
|
37
|
+
self._models: Optional[List[Model]] = None
|
|
38
|
+
self._apps: Optional[List[App]] = None
|
|
39
|
+
|
|
40
|
+
# ─── HTTP ──────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
def _request(self, method: str, path: str, body: Optional[Dict[str, Any]] = None, *, retries: int = 0) -> Dict[str, Any]:
|
|
43
|
+
url = f"{self.base_url}/api/v1{path}"
|
|
44
|
+
headers = {"Content-Type": "application/json", "Accept": "application/json", "User-Agent": f"tanvo-python/{__version__}"}
|
|
45
|
+
if self.api_key:
|
|
46
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
47
|
+
else:
|
|
48
|
+
headers["x-anon-id"] = self.anon_id or ""
|
|
49
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
50
|
+
delays = [1, 3]
|
|
51
|
+
rate_limited_once = False
|
|
52
|
+
attempt = 0
|
|
53
|
+
while True:
|
|
54
|
+
try:
|
|
55
|
+
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
56
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as res:
|
|
57
|
+
raw = res.read()
|
|
58
|
+
return json.loads(raw) if raw else {}
|
|
59
|
+
except urllib.error.HTTPError as e:
|
|
60
|
+
err = self._error(e)
|
|
61
|
+
except (urllib.error.URLError, TimeoutError, ConnectionError, OSError) as e:
|
|
62
|
+
err = ServiceError("network", f"Could not reach {self.base_url}: {getattr(e, 'reason', e)}", 0)
|
|
63
|
+
if err.code == "rate_limited" and not rate_limited_once:
|
|
64
|
+
rate_limited_once = True
|
|
65
|
+
time.sleep(min(err.retry_after or 5, 60))
|
|
66
|
+
continue
|
|
67
|
+
if isinstance(err, ServiceError) and attempt < retries:
|
|
68
|
+
time.sleep(delays[min(attempt, len(delays) - 1)])
|
|
69
|
+
attempt += 1
|
|
70
|
+
continue
|
|
71
|
+
raise err
|
|
72
|
+
|
|
73
|
+
def _error(self, e: urllib.error.HTTPError) -> TanvoError:
|
|
74
|
+
try:
|
|
75
|
+
payload = json.loads(e.read() or b"{}")
|
|
76
|
+
except ValueError:
|
|
77
|
+
payload = {}
|
|
78
|
+
retry = e.headers.get("Retry-After") if e.headers else None
|
|
79
|
+
return error_for(payload.get("error") or f"http_{e.code}", payload.get("message") or f"HTTP {e.code}", e.code, payload.get("issues"), float(retry) if retry and retry.isdigit() else None, has_key=bool(self.api_key))
|
|
80
|
+
|
|
81
|
+
# ─── Catalogue and account ─────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
def models(self, kind: Optional[str] = None) -> List[Model]:
|
|
84
|
+
"""Every model with its options, defaults and credit cost at defaults. Cached on the client."""
|
|
85
|
+
if self._models is None:
|
|
86
|
+
self._models = [Model.from_dict(m) for m in self._request("GET", "/models", retries=2)["models"]]
|
|
87
|
+
return [m for m in self._models if kind is None or m.kind == kind]
|
|
88
|
+
|
|
89
|
+
def model(self, model_id: str) -> Model:
|
|
90
|
+
for m in self.models():
|
|
91
|
+
if m.id == model_id:
|
|
92
|
+
return m
|
|
93
|
+
raise KeyError(f"Unknown model {model_id!r}. See client.models().")
|
|
94
|
+
|
|
95
|
+
def apps(self, kind: Optional[str] = None) -> List[App]:
|
|
96
|
+
"""Ready-made apps (pet portraits, figurines, trend videos, songs…) with their preset looks. Cached on the client."""
|
|
97
|
+
if self._apps is None:
|
|
98
|
+
self._apps = [App.from_dict(a) for a in self._request("GET", "/apps", retries=2)["apps"]]
|
|
99
|
+
return [a for a in self._apps if kind is None or a.kind == kind]
|
|
100
|
+
|
|
101
|
+
def app(self, slug: str) -> App:
|
|
102
|
+
for a in self.apps():
|
|
103
|
+
if a.slug == slug:
|
|
104
|
+
return a
|
|
105
|
+
raise KeyError(f"Unknown app {slug!r}. See client.apps().")
|
|
106
|
+
|
|
107
|
+
def me(self) -> Account:
|
|
108
|
+
d = self._request("GET", "/me", retries=2)
|
|
109
|
+
return Account(tier=d.get("tier", ""), credits=d.get("credits", 0), watermarked=bool(d.get("watermarked")), concurrency=d.get("concurrency", 1), email=d.get("email"))
|
|
110
|
+
|
|
111
|
+
# ─── Generation ────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
def _options(self, model_id: str, given: Dict[str, Any]) -> Dict[str, Any]:
|
|
114
|
+
"""The model's defaults, overridden by whatever the caller set."""
|
|
115
|
+
try:
|
|
116
|
+
base = dict(self.model(model_id).defaults)
|
|
117
|
+
except KeyError:
|
|
118
|
+
base = {}
|
|
119
|
+
base.update({k: v for k, v in given.items() if v is not None})
|
|
120
|
+
return base
|
|
121
|
+
|
|
122
|
+
def _submit(self, body: Dict[str, Any], wait: bool, timeout: float, wait_for_slot: bool) -> Generation:
|
|
123
|
+
body = {k: v for k, v in body.items() if v not in (None, [], "")}
|
|
124
|
+
body.setdefault("requestKey", str(uuid.uuid4())) # makes the retries below safe: a repeat returns the first run
|
|
125
|
+
deadline = time.monotonic() + timeout
|
|
126
|
+
while True:
|
|
127
|
+
try:
|
|
128
|
+
g = Generation.from_dict(self._request("POST", "/generations", body, retries=2)["generation"])
|
|
129
|
+
break
|
|
130
|
+
except TanvoError as e:
|
|
131
|
+
if e.code == "busy" and wait_for_slot and time.monotonic() + 5 < deadline:
|
|
132
|
+
time.sleep(5)
|
|
133
|
+
continue
|
|
134
|
+
raise
|
|
135
|
+
if wait:
|
|
136
|
+
return self.wait(g.id, timeout=max(1.0, deadline - time.monotonic()), every=6 if g.kind == "video" else 4)
|
|
137
|
+
return g
|
|
138
|
+
|
|
139
|
+
def generate_image(
|
|
140
|
+
self, prompt: str, *, model: str = "studio-image-v1", aspect: Optional[str] = None, resolution: Optional[str] = None, format: Optional[str] = None,
|
|
141
|
+
images: Sequence[ImageInput] = (), negative_prompt: Optional[str] = None, wait: bool = True, timeout: float = 180, request_key: Optional[str] = None,
|
|
142
|
+
webhook_url: Optional[str] = None, wait_for_slot: bool = False,
|
|
143
|
+
) -> Generation:
|
|
144
|
+
"""Text to image, or an edit / combination when `images` are given (https URLs, local paths or bytes)."""
|
|
145
|
+
return self._submit({
|
|
146
|
+
"kind": "image", "model": model, "prompt": prompt, "negativePrompt": negative_prompt,
|
|
147
|
+
"options": self._options(model, {"aspect": aspect, "resolution": resolution, "format": format}),
|
|
148
|
+
"imageUrls": [self._ref(i) for i in images], "requestKey": request_key, "webhookUrl": webhook_url,
|
|
149
|
+
}, wait, timeout, wait_for_slot)
|
|
150
|
+
|
|
151
|
+
def generate_video(
|
|
152
|
+
self, prompt: str, *, model: str = "studio-video-v1", aspect: Optional[str] = None, resolution: Optional[str] = None, duration: Optional[int] = None,
|
|
153
|
+
audio: Optional[bool] = None, image: Optional[ImageInput] = None, end_image: Optional[ImageInput] = None, negative_prompt: Optional[str] = None,
|
|
154
|
+
wait: bool = False, timeout: float = 600, request_key: Optional[str] = None, webhook_url: Optional[str] = None, wait_for_slot: bool = False,
|
|
155
|
+
) -> Generation:
|
|
156
|
+
"""Text to video, or image to video from `image` (optionally ending on `end_image`). Returns at once unless wait=True."""
|
|
157
|
+
return self._submit({
|
|
158
|
+
"kind": "video", "model": model, "prompt": prompt, "negativePrompt": negative_prompt,
|
|
159
|
+
"options": self._options(model, {"aspect": aspect, "resolution": resolution, "duration": duration, "audio": audio}),
|
|
160
|
+
"imageUrls": [self._ref(image)] if image is not None else [], "endImageUrl": self._ref(end_image) if end_image is not None else None,
|
|
161
|
+
"requestKey": request_key, "webhookUrl": webhook_url,
|
|
162
|
+
}, wait, timeout, wait_for_slot)
|
|
163
|
+
|
|
164
|
+
def generate_music(
|
|
165
|
+
self, prompt: str, *, mode: str = "Describe", style: Optional[str] = None, title: Optional[str] = None, vocal: Optional[str] = None,
|
|
166
|
+
model: str = "suno-v6", wait: bool = True, timeout: float = 300, request_key: Optional[str] = None, webhook_url: Optional[str] = None,
|
|
167
|
+
wait_for_slot: bool = False,
|
|
168
|
+
) -> Generation:
|
|
169
|
+
"""Two takes of a song. mode: "Describe" (prompt describes the song), "Lyrics" (prompt is your lyrics) or "Instrumental"."""
|
|
170
|
+
return self._submit({
|
|
171
|
+
"kind": "music", "model": model, "prompt": prompt,
|
|
172
|
+
"options": {k: v for k, v in {"tier": mode, "resolution": "song", "style": style, "title": title, "vocal": vocal}.items() if v is not None},
|
|
173
|
+
"requestKey": request_key, "webhookUrl": webhook_url,
|
|
174
|
+
}, wait, timeout, wait_for_slot)
|
|
175
|
+
|
|
176
|
+
def generate_from_app(
|
|
177
|
+
self, app: str, *, look: Optional[str] = None, photos: Sequence[ImageInput] = (), details: Optional[str] = None, wait: Optional[bool] = None,
|
|
178
|
+
timeout: Optional[float] = None, request_key: Optional[str] = None, webhook_url: Optional[str] = None, wait_for_slot: bool = False,
|
|
179
|
+
) -> Generation:
|
|
180
|
+
"""Runs one of an app's preset looks on your photos (in the order of app.inputs), e.g.
|
|
181
|
+
client.generate_from_app("ai-pet-portrait-generator", look="royal", photos=["rex.jpg"])."""
|
|
182
|
+
a = self.app(app)
|
|
183
|
+
l = a.look(look)
|
|
184
|
+
needed = sum(1 for i in a.inputs if i.min > 0)
|
|
185
|
+
if len(photos) < needed:
|
|
186
|
+
raise ValueError(f"{a.name} needs {needed} photo(s): {', '.join(i.label for i in a.inputs if i.min > 0)}.")
|
|
187
|
+
prompt = f"{l.prompt}\n\nThis one is for: {details.strip()}." if details else l.prompt
|
|
188
|
+
is_video = a.kind == "video"
|
|
189
|
+
return self._submit({
|
|
190
|
+
"kind": a.kind, "model": l.model, "prompt": prompt, "options": self._options(l.model, dict(l.options)),
|
|
191
|
+
"imageUrls": [self._ref(p) for p in photos] if a.kind != "music" else [], "requestKey": request_key, "webhookUrl": webhook_url,
|
|
192
|
+
}, (not is_video) if wait is None else wait, timeout or (600 if is_video else 300 if a.kind == "music" else 180), wait_for_slot)
|
|
193
|
+
|
|
194
|
+
# ─── Runs ──────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
def get(self, generation_id: str) -> Generation:
|
|
197
|
+
return Generation.from_dict(self._request("GET", f"/generations/{urllib.parse.quote(generation_id)}", retries=2)["generation"])
|
|
198
|
+
|
|
199
|
+
def wait(self, generation_id: str, timeout: float = 600, every: float = 4) -> Generation:
|
|
200
|
+
"""Polls until the run finishes. On timeout returns the last state seen (still running) instead of raising."""
|
|
201
|
+
deadline = time.monotonic() + timeout
|
|
202
|
+
time.sleep(min(2, timeout))
|
|
203
|
+
last: Optional[Generation] = None
|
|
204
|
+
while True:
|
|
205
|
+
try:
|
|
206
|
+
last = self.get(generation_id)
|
|
207
|
+
if last.done:
|
|
208
|
+
return last
|
|
209
|
+
except ServiceError:
|
|
210
|
+
pass # one failed read is not a failed run
|
|
211
|
+
if time.monotonic() + every > deadline:
|
|
212
|
+
return last if last is not None else self.get(generation_id)
|
|
213
|
+
time.sleep(every)
|
|
214
|
+
|
|
215
|
+
def list(self, limit: int = 20, before: Optional[int] = None) -> Tuple[List[Generation], Optional[int]]:
|
|
216
|
+
q = urllib.parse.urlencode({k: v for k, v in {"limit": limit, "before": before}.items() if v is not None})
|
|
217
|
+
d = self._request("GET", f"/generations?{q}", retries=2)
|
|
218
|
+
return [Generation.from_dict(g) for g in d.get("generations", [])], d.get("next_before")
|
|
219
|
+
|
|
220
|
+
# ─── Files ─────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
def upload(self, file: Union[str, os.PathLike, bytes], mime: Optional[str] = None) -> str:
|
|
223
|
+
"""Uploads a local file (path or bytes) and returns a URL usable in images= / image=."""
|
|
224
|
+
if isinstance(file, bytes):
|
|
225
|
+
data = file
|
|
226
|
+
if not mime:
|
|
227
|
+
raise ValueError("Pass mime= when uploading bytes, e.g. mime='image/png'.")
|
|
228
|
+
else:
|
|
229
|
+
p = Path(file).expanduser()
|
|
230
|
+
data = p.read_bytes()
|
|
231
|
+
mime = mime or _MIME.get(p.suffix.lower()) or mimetypes.guess_type(p.name)[0]
|
|
232
|
+
if not mime:
|
|
233
|
+
raise ValueError(f"Cannot tell the file type of {p.name}; pass mime=.")
|
|
234
|
+
slot = self._request("POST", "/uploads", {"mime": mime, "bytes": len(data)})
|
|
235
|
+
req = urllib.request.Request(slot["upload_url"], data=data, method="PUT", headers={"Content-Type": mime})
|
|
236
|
+
try:
|
|
237
|
+
with urllib.request.urlopen(req, timeout=max(self.timeout, 120)):
|
|
238
|
+
pass
|
|
239
|
+
except urllib.error.HTTPError as e:
|
|
240
|
+
raise ServiceError("upload_failed", f"Upload failed with HTTP {e.code}.", e.code) from None
|
|
241
|
+
return slot["url"]
|
|
242
|
+
|
|
243
|
+
def _ref(self, ref: ImageInput) -> str:
|
|
244
|
+
if isinstance(ref, str) and ref.startswith("https://"):
|
|
245
|
+
return ref
|
|
246
|
+
if isinstance(ref, str) and ref.startswith("http://") and not self.base_url.startswith("http://"):
|
|
247
|
+
raise ValueError(f"Only https URLs can be fetched by the service: {ref}")
|
|
248
|
+
if isinstance(ref, bytes):
|
|
249
|
+
return self.upload(ref, mime="image/png" if ref[:8] == b"\x89PNG\r\n\x1a\n" else "image/jpeg" if ref[:3] == b"\xff\xd8\xff" else "image/webp")
|
|
250
|
+
return self.upload(ref)
|
|
251
|
+
|
|
252
|
+
def download(self, generation: Union[Generation, str], path: Union[str, os.PathLike] = ".", index: int = 0) -> str:
|
|
253
|
+
"""Saves one output to `path` (a folder or a file name) and returns the file path."""
|
|
254
|
+
g = self.get(generation) if isinstance(generation, str) else generation
|
|
255
|
+
if not g.outputs:
|
|
256
|
+
raise ValueError(f"Generation {g.id} has no outputs (status {g.status}).")
|
|
257
|
+
url = g.outputs[index].url
|
|
258
|
+
target = Path(path).expanduser()
|
|
259
|
+
if target.is_dir() or str(path).endswith(("/", os.sep)):
|
|
260
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
target = target / f"tanvo-{g.id}-{index}{Path(urllib.parse.urlparse(url).path).suffix or '.bin'}"
|
|
262
|
+
with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": f"tanvo-python/{__version__}"}), timeout=max(self.timeout, 120)) as res:
|
|
263
|
+
target.write_bytes(res.read())
|
|
264
|
+
return str(target)
|
|
265
|
+
|
|
266
|
+
verify_webhook = staticmethod(verify_webhook)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
KEYS_URL = "https://tanvo.ai/settings/apikeys"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TanvoError(Exception):
|
|
9
|
+
"""Base class. `code` is the API's stable error string, `status` the HTTP status (0 for network errors)."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, code: str, message: str, status: int = 0, issues: Optional[List[str]] = None, retry_after: Optional[float] = None):
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.code = code
|
|
14
|
+
self.message = message
|
|
15
|
+
self.status = status
|
|
16
|
+
self.issues = issues or []
|
|
17
|
+
self.retry_after = retry_after
|
|
18
|
+
|
|
19
|
+
def __str__(self) -> str:
|
|
20
|
+
extra = f" ({'; '.join(self.issues)})" if self.issues else ""
|
|
21
|
+
return f"{self.code}: {self.message}{extra}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvalidRequest(TanvoError):
|
|
25
|
+
"""Bad parameters, a prompt the moderator refused, a file too large, or a record that does not exist. Do not retry."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AuthError(TanvoError):
|
|
29
|
+
"""No identity, or an invalid / revoked API key."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class QuotaError(TanvoError):
|
|
33
|
+
"""Out of credits, the free tier does not cover this, or a limit was hit. `busy` and `rate_limited` clear by waiting."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ServiceError(TanvoError):
|
|
37
|
+
"""The service or an upstream model failed. Safe to retry with the same request_key."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class WebhookVerificationError(TanvoError):
|
|
41
|
+
"""A webhook's signature or timestamp did not check out."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_KINDS = {
|
|
45
|
+
InvalidRequest: {"invalid", "rejected", "too_large", "not_found"},
|
|
46
|
+
AuthError: {"unauthorized", "invalid_api_key", "unknown_anon_id"},
|
|
47
|
+
QuotaError: {"credits", "allowance", "needs_api_key", "anon_ip_daily", "trial_closed", "rate_limited", "limit", "busy"},
|
|
48
|
+
}
|
|
49
|
+
NEEDS_KEY = {"needs_api_key", "allowance", "anon_ip_daily", "trial_closed"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def error_for(code: str, message: str, status: int, issues: Optional[List[str]] = None, retry_after: Optional[float] = None, has_key: bool = True) -> TanvoError:
|
|
53
|
+
cls = next((c for c, codes in _KINDS.items() if code in codes), ServiceError)
|
|
54
|
+
if code in NEEDS_KEY and not has_key:
|
|
55
|
+
message = f"{message} Get a key at {KEYS_URL} (then pass api_key= or set TANVO_API_KEY)."
|
|
56
|
+
return cls(code, message, status, issues, retry_after)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
TERMINAL = {"SUCCEEDED", "FAILED", "CANCELED"}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Output:
|
|
11
|
+
url: str
|
|
12
|
+
mime: str
|
|
13
|
+
# Songs only.
|
|
14
|
+
cover: Optional[str] = None
|
|
15
|
+
title: Optional[str] = None
|
|
16
|
+
lyrics: Optional[str] = None
|
|
17
|
+
seconds: Optional[float] = None
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def from_dict(cls, d: Dict[str, Any]) -> "Output":
|
|
21
|
+
return cls(url=d["url"], mime=d.get("mime", ""), cover=d.get("cover"), title=d.get("title"), lyrics=d.get("lyrics"), seconds=d.get("seconds"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Generation:
|
|
26
|
+
id: str
|
|
27
|
+
kind: str
|
|
28
|
+
model: str
|
|
29
|
+
prompt: str
|
|
30
|
+
options: Dict[str, Any]
|
|
31
|
+
cost: int
|
|
32
|
+
status: str
|
|
33
|
+
error: Optional[str]
|
|
34
|
+
created_at: int
|
|
35
|
+
outputs: List[Output] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_dict(cls, d: Dict[str, Any]) -> "Generation":
|
|
39
|
+
return cls(
|
|
40
|
+
id=d["id"],
|
|
41
|
+
kind=d.get("kind", ""),
|
|
42
|
+
model=d.get("model", ""),
|
|
43
|
+
prompt=d.get("prompt", ""),
|
|
44
|
+
options=d.get("options") or {},
|
|
45
|
+
cost=d.get("cost", 0),
|
|
46
|
+
status=d.get("status", ""),
|
|
47
|
+
error=d.get("error"),
|
|
48
|
+
created_at=d.get("createdAt", 0),
|
|
49
|
+
outputs=[Output.from_dict(o) for o in d.get("outputs") or []],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def done(self) -> bool:
|
|
54
|
+
return self.status in TERMINAL
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def succeeded(self) -> bool:
|
|
58
|
+
return self.status == "SUCCEEDED"
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def url(self) -> Optional[str]:
|
|
62
|
+
"""The first output's URL, or None while running or after a failure."""
|
|
63
|
+
return self.outputs[0].url if self.outputs else None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class Model:
|
|
68
|
+
id: str
|
|
69
|
+
kind: str
|
|
70
|
+
name: str
|
|
71
|
+
free_tier: bool
|
|
72
|
+
inputs: List[str]
|
|
73
|
+
options: Dict[str, Any]
|
|
74
|
+
defaults: Dict[str, Any]
|
|
75
|
+
credits_at_defaults: int
|
|
76
|
+
prompt_max_chars: int
|
|
77
|
+
raw: Dict[str, Any] = field(repr=False, default_factory=dict)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_dict(cls, d: Dict[str, Any]) -> "Model":
|
|
81
|
+
return cls(
|
|
82
|
+
id=d["id"], kind=d.get("kind", ""), name=d.get("name", ""), free_tier=bool(d.get("free_tier")), inputs=d.get("inputs") or [],
|
|
83
|
+
options=d.get("options") or {}, defaults=d.get("defaults") or {}, credits_at_defaults=d.get("credits_at_defaults", 0),
|
|
84
|
+
prompt_max_chars=d.get("prompt_max_chars", 0), raw=d,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class Look:
|
|
90
|
+
id: str
|
|
91
|
+
name: str
|
|
92
|
+
prompt: str
|
|
93
|
+
model: str
|
|
94
|
+
options: Dict[str, Any]
|
|
95
|
+
example: Optional[str]
|
|
96
|
+
url: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class AppInput:
|
|
101
|
+
id: str
|
|
102
|
+
label: str
|
|
103
|
+
hint: str
|
|
104
|
+
min: int
|
|
105
|
+
max: int
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class App:
|
|
110
|
+
slug: str
|
|
111
|
+
kind: str
|
|
112
|
+
name: str
|
|
113
|
+
description: str
|
|
114
|
+
category: Optional[str]
|
|
115
|
+
model: str
|
|
116
|
+
url: str
|
|
117
|
+
inputs: List[AppInput]
|
|
118
|
+
looks: List[Look]
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def from_dict(cls, d: Dict[str, Any]) -> "App":
|
|
122
|
+
return cls(
|
|
123
|
+
slug=d["slug"], kind=d.get("kind", ""), name=d.get("name", ""), description=d.get("description", ""), category=d.get("category"),
|
|
124
|
+
model=d.get("model", ""), url=d.get("url", ""),
|
|
125
|
+
inputs=[AppInput(id=i["id"], label=i.get("label", ""), hint=i.get("hint", ""), min=i.get("min", 1), max=i.get("max", 1)) for i in d.get("inputs") or []],
|
|
126
|
+
looks=[
|
|
127
|
+
Look(id=l["id"], name=l.get("name", ""), prompt=l.get("prompt", ""), model=l.get("model", ""), options=l.get("options") or {},
|
|
128
|
+
example=(l.get("example") or {}).get("video") or (l.get("example") or {}).get("url"), url=l.get("url", ""))
|
|
129
|
+
for l in d.get("looks") or []
|
|
130
|
+
],
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def look(self, look_id: Optional[str] = None) -> Look:
|
|
134
|
+
if look_id is None:
|
|
135
|
+
return self.looks[0]
|
|
136
|
+
for l in self.looks:
|
|
137
|
+
if l.id == look_id:
|
|
138
|
+
return l
|
|
139
|
+
raise KeyError(f"{self.slug} has no look {look_id!r}; looks: {', '.join(l.id for l in self.looks)}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class Account:
|
|
144
|
+
tier: str
|
|
145
|
+
credits: int
|
|
146
|
+
watermarked: bool
|
|
147
|
+
concurrency: int
|
|
148
|
+
email: Optional[str] = None
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from typing import Mapping, Optional, Union
|
|
9
|
+
|
|
10
|
+
from ._errors import WebhookVerificationError
|
|
11
|
+
from ._types import Generation
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _header(headers: Mapping[str, str], name: str) -> Optional[str]:
|
|
15
|
+
for k, v in headers.items():
|
|
16
|
+
if k.lower() == name:
|
|
17
|
+
return v
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def verify_webhook(secret: str, headers: Mapping[str, str], body: Union[bytes, str], tolerance: int = 300) -> Generation:
|
|
22
|
+
"""Checks a completion webhook (Standard Webhooks) and returns the Generation it carries.
|
|
23
|
+
|
|
24
|
+
Pass the raw request body exactly as received, not re-serialised JSON. Raises WebhookVerificationError when the
|
|
25
|
+
signature does not match or the timestamp is more than `tolerance` seconds away. The same `webhook-id` can arrive
|
|
26
|
+
more than once (retries, manual resends): de-duplicate on it.
|
|
27
|
+
"""
|
|
28
|
+
raw = body.encode() if isinstance(body, str) else body
|
|
29
|
+
msg_id, ts, sig = _header(headers, "webhook-id"), _header(headers, "webhook-timestamp"), _header(headers, "webhook-signature")
|
|
30
|
+
if not msg_id or not ts or not sig:
|
|
31
|
+
raise WebhookVerificationError("missing_headers", "webhook-id, webhook-timestamp and webhook-signature are required.")
|
|
32
|
+
try:
|
|
33
|
+
sent = int(ts)
|
|
34
|
+
except ValueError:
|
|
35
|
+
raise WebhookVerificationError("bad_timestamp", "webhook-timestamp is not a number.") from None
|
|
36
|
+
if abs(time.time() - sent) > tolerance:
|
|
37
|
+
raise WebhookVerificationError("stale", f"The webhook timestamp is more than {tolerance} s from now.")
|
|
38
|
+
key = base64.b64decode(secret[len("whsec_"):] if secret.startswith("whsec_") else secret)
|
|
39
|
+
expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + raw, hashlib.sha256).digest()).decode()
|
|
40
|
+
candidates = [part.split(",", 1)[1] for part in sig.split() if part.startswith("v1,")]
|
|
41
|
+
if not any(hmac.compare_digest(expected, c) for c in candidates):
|
|
42
|
+
raise WebhookVerificationError("bad_signature", "The webhook signature does not match. Check the secret under Settings → Webhooks.")
|
|
43
|
+
return Generation.from_dict(json.loads(raw))
|
|
File without changes
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Offline tests against a fake API server (standard library only): python -m unittest discover -s tests"""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import unittest
|
|
13
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
14
|
+
from unittest import mock
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
17
|
+
|
|
18
|
+
import tanvo # noqa: E402
|
|
19
|
+
from tanvo import AuthError, Client, InvalidRequest, QuotaError, ServiceError, WebhookVerificationError, verify_webhook # noqa: E402
|
|
20
|
+
|
|
21
|
+
PNG = b"\x89PNG\r\n\x1a\n" + b"0" * 32
|
|
22
|
+
MODELS = [
|
|
23
|
+
{"id": "studio-image-v1", "kind": "image", "name": "Tanvo Image v1", "free_tier": True, "inputs": ["text", "image"], "options": {}, "defaults": {"aspect": "1:1", "resolution": "1K", "format": "PNG"}, "credits_at_defaults": 5, "prompt_max_chars": 2000},
|
|
24
|
+
{"id": "seedream-4", "kind": "image", "name": "Seedream 4.0", "free_tier": False, "inputs": ["text", "image"], "options": {}, "defaults": {"aspect": "1:1", "resolution": "2K"}, "credits_at_defaults": 10, "prompt_max_chars": 5000},
|
|
25
|
+
{"id": "studio-video-v1", "kind": "video", "name": "Tanvo Video v1", "free_tier": True, "inputs": ["text", "image"], "options": {}, "defaults": {"aspect": "16:9", "resolution": "480p", "duration": 5}, "credits_at_defaults": 20, "prompt_max_chars": 2000},
|
|
26
|
+
]
|
|
27
|
+
APPS = [{
|
|
28
|
+
"slug": "ai-pet-portrait-generator", "kind": "image", "name": "Pet Portrait Generator", "description": "A painted portrait.", "category": "Pets", "model": "seedream-4",
|
|
29
|
+
"url": "https://tanvo.ai/image/ai-pet-portrait-generator", "inputs": [{"id": "in1", "label": "Pet photo", "hint": "", "accept": "image/*", "min": 1, "max": 1}],
|
|
30
|
+
"looks": [{"id": "renaissance", "name": "Renaissance oil", "prompt": "Paint the pet as a Renaissance oil portrait.", "model": "seedream-4", "options": {}, "example": {"url": "https://x/e.webp", "kind": "image"}, "url": "https://tanvo.ai/image/ai-pet-portrait-generator?look=renaissance"},
|
|
31
|
+
{"id": "royal", "name": "Royal monarch", "prompt": "Paint the pet as a monarch.", "model": "seedream-4", "options": {"aspect": "3:4"}, "example": None, "url": "https://tanvo.ai/image/ai-pet-portrait-generator?look=royal"}],
|
|
32
|
+
}]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Fake:
|
|
36
|
+
"""State shared with the handler: what was asked, and which failures to inject next."""
|
|
37
|
+
|
|
38
|
+
def __init__(self):
|
|
39
|
+
self.reset()
|
|
40
|
+
|
|
41
|
+
def reset(self):
|
|
42
|
+
self.requests = []
|
|
43
|
+
self.runs = {}
|
|
44
|
+
self.by_key = {}
|
|
45
|
+
self.fail_next = [] # (status, error, extra_headers)
|
|
46
|
+
self.polls_until_done = 1
|
|
47
|
+
self.uploaded = {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
FAKE = Fake()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Handler(BaseHTTPRequestHandler):
|
|
54
|
+
def log_message(self, *a):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
def _send(self, status, payload, headers=None):
|
|
58
|
+
body = json.dumps(payload).encode()
|
|
59
|
+
self.send_response(status)
|
|
60
|
+
self.send_header("Content-Type", "application/json")
|
|
61
|
+
for k, v in (headers or {}).items():
|
|
62
|
+
self.send_header(k, v)
|
|
63
|
+
self.send_header("Content-Length", str(len(body)))
|
|
64
|
+
self.end_headers()
|
|
65
|
+
self.wfile.write(body)
|
|
66
|
+
|
|
67
|
+
def _body(self):
|
|
68
|
+
n = int(self.headers.get("Content-Length") or 0)
|
|
69
|
+
return self.rfile.read(n) if n else b""
|
|
70
|
+
|
|
71
|
+
def _handle(self, method):
|
|
72
|
+
raw = self._body()
|
|
73
|
+
FAKE.requests.append((method, self.path, dict(self.headers), raw))
|
|
74
|
+
if self.path.startswith("/put/"):
|
|
75
|
+
FAKE.uploaded[self.path] = (self.headers.get("Content-Type"), raw)
|
|
76
|
+
self.send_response(200)
|
|
77
|
+
self.send_header("Content-Length", "0")
|
|
78
|
+
self.end_headers()
|
|
79
|
+
return
|
|
80
|
+
if self.path.startswith("/files/"):
|
|
81
|
+
self.send_response(200)
|
|
82
|
+
self.send_header("Content-Length", str(len(PNG)))
|
|
83
|
+
self.end_headers()
|
|
84
|
+
self.wfile.write(PNG)
|
|
85
|
+
return
|
|
86
|
+
if FAKE.fail_next:
|
|
87
|
+
status, error, headers = FAKE.fail_next.pop(0)
|
|
88
|
+
return self._send(status, {"error": error, "message": f"injected {error}"}, headers)
|
|
89
|
+
auth = self.headers.get("Authorization")
|
|
90
|
+
if auth and auth != "Bearer sk_good":
|
|
91
|
+
return self._send(401, {"error": "invalid_api_key", "message": "That API key is not valid."})
|
|
92
|
+
path = self.path.split("?")[0]
|
|
93
|
+
if method == "GET" and path == "/api/v1/models":
|
|
94
|
+
return self._send(200, {"models": MODELS})
|
|
95
|
+
if method == "GET" and path == "/api/v1/apps":
|
|
96
|
+
return self._send(200, {"apps": APPS})
|
|
97
|
+
if method == "GET" and path == "/api/v1/me":
|
|
98
|
+
return self._send(200, {"tier": "paid" if auth else "anonymous", "credits": 900 if auth else 5, "watermarked": not auth, "concurrency": 3 if auth else 1})
|
|
99
|
+
if method == "POST" and path == "/api/v1/generations":
|
|
100
|
+
b = json.loads(raw)
|
|
101
|
+
if not auth and b["model"] != "studio-image-v1":
|
|
102
|
+
return self._send(403, {"error": "needs_api_key", "message": "The free tier covers the house image engine only."})
|
|
103
|
+
if b["options"].get("resolution") == "8K":
|
|
104
|
+
return self._send(400, {"error": "invalid", "message": "That model or setting is not available.", "issues": ["options.resolution: 8K"]})
|
|
105
|
+
key = b.get("requestKey")
|
|
106
|
+
if key in FAKE.by_key:
|
|
107
|
+
return self._send(202, {"generation": FAKE.runs[FAKE.by_key[key]]["g"]})
|
|
108
|
+
gid = f"gen{len(FAKE.runs) + 1}"
|
|
109
|
+
g = {"id": gid, "kind": b["kind"], "model": b["model"], "prompt": b["prompt"], "options": b["options"], "cost": 5, "status": "PROCESSING", "error": None, "createdAt": 1790000000000 + len(FAKE.runs), "outputs": []}
|
|
110
|
+
FAKE.runs[gid] = {"g": g, "polls": 0, "body": b}
|
|
111
|
+
FAKE.by_key[key] = gid
|
|
112
|
+
return self._send(202, {"generation": g})
|
|
113
|
+
if method == "GET" and path.startswith("/api/v1/generations/"):
|
|
114
|
+
r = FAKE.runs.get(path.rsplit("/", 1)[1])
|
|
115
|
+
if not r:
|
|
116
|
+
return self._send(404, {"error": "not_found", "message": "No such generation."})
|
|
117
|
+
r["polls"] += 1
|
|
118
|
+
if r["polls"] >= FAKE.polls_until_done:
|
|
119
|
+
host = f"http://127.0.0.1:{self.server.server_address[1]}"
|
|
120
|
+
r["g"] = {**r["g"], "status": "SUCCEEDED", "outputs": [{"url": f"{host}/files/{r['g']['id']}/0.png", "mime": "image/png"}]}
|
|
121
|
+
return self._send(200, {"generation": r["g"]})
|
|
122
|
+
if method == "GET" and path == "/api/v1/generations":
|
|
123
|
+
gs = sorted((r["g"] for r in FAKE.runs.values()), key=lambda g: -g["createdAt"])[:2]
|
|
124
|
+
return self._send(200, {"generations": gs, "next_before": gs[-1]["createdAt"] if gs else None})
|
|
125
|
+
if method == "POST" and path == "/api/v1/uploads":
|
|
126
|
+
b = json.loads(raw)
|
|
127
|
+
host = f"http://127.0.0.1:{self.server.server_address[1]}"
|
|
128
|
+
return self._send(200, {"upload_url": f"{host}/put/{len(FAKE.uploaded)}", "url": f"https://media.tanvo.ai/uploads/u/{len(FAKE.uploaded)}.png", "expires_in": 300, "_mime": b["mime"]})
|
|
129
|
+
self._send(404, {"error": "not_found", "message": self.path})
|
|
130
|
+
|
|
131
|
+
def do_GET(self):
|
|
132
|
+
self._handle("GET")
|
|
133
|
+
|
|
134
|
+
def do_POST(self):
|
|
135
|
+
self._handle("POST")
|
|
136
|
+
|
|
137
|
+
def do_PUT(self):
|
|
138
|
+
self._handle("PUT")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class ClientTest(unittest.TestCase):
|
|
142
|
+
@classmethod
|
|
143
|
+
def setUpClass(cls):
|
|
144
|
+
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
145
|
+
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
|
|
146
|
+
cls.base = f"http://127.0.0.1:{cls.server.server_address[1]}"
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def tearDownClass(cls):
|
|
150
|
+
cls.server.shutdown()
|
|
151
|
+
|
|
152
|
+
def setUp(self):
|
|
153
|
+
FAKE.reset()
|
|
154
|
+
# Polling and retry delays are real sleeps; make them instant.
|
|
155
|
+
self.sleep = mock.patch("tanvo._client.time.sleep", lambda s: None)
|
|
156
|
+
self.sleep.start()
|
|
157
|
+
os.environ.pop("TANVO_API_KEY", None)
|
|
158
|
+
|
|
159
|
+
def tearDown(self):
|
|
160
|
+
self.sleep.stop()
|
|
161
|
+
|
|
162
|
+
def client(self, key=None):
|
|
163
|
+
return Client(key, base_url=self.base)
|
|
164
|
+
|
|
165
|
+
def test_anonymous_free_image(self):
|
|
166
|
+
c = self.client()
|
|
167
|
+
self.assertEqual(c.me().tier, "anonymous")
|
|
168
|
+
g = c.generate_image("a cat")
|
|
169
|
+
self.assertTrue(g.succeeded)
|
|
170
|
+
self.assertEqual(g.cost, 5)
|
|
171
|
+
post = next(r for r in FAKE.requests if r[0] == "POST" and r[1] == "/api/v1/generations")
|
|
172
|
+
sent = json.loads(post[3])
|
|
173
|
+
self.assertEqual(sent["options"], {"aspect": "1:1", "resolution": "1K", "format": "PNG"}) # model defaults filled in
|
|
174
|
+
self.assertTrue(sent["requestKey"])
|
|
175
|
+
headers = post[2]
|
|
176
|
+
self.assertTrue(headers["X-Anon-Id"].startswith("py-"))
|
|
177
|
+
self.assertTrue(headers["User-Agent"].startswith("tanvo-python/"))
|
|
178
|
+
|
|
179
|
+
def test_free_tier_refuses_other_models_with_a_hint(self):
|
|
180
|
+
with self.assertRaises(QuotaError) as e:
|
|
181
|
+
self.client().generate_video("a fox")
|
|
182
|
+
self.assertEqual(e.exception.code, "needs_api_key")
|
|
183
|
+
self.assertIn("settings/apikeys", str(e.exception))
|
|
184
|
+
|
|
185
|
+
def test_key_options_override_defaults(self):
|
|
186
|
+
c = self.client("sk_good")
|
|
187
|
+
g = c.generate_image("a cat", model="seedream-4", aspect="16:9")
|
|
188
|
+
self.assertTrue(g.succeeded)
|
|
189
|
+
self.assertEqual(json.loads(FAKE.requests[-2][3])["options"], {"aspect": "16:9", "resolution": "2K"})
|
|
190
|
+
self.assertEqual(FAKE.requests[-1][2]["Authorization"], "Bearer sk_good")
|
|
191
|
+
|
|
192
|
+
def test_request_key_is_idempotent(self):
|
|
193
|
+
c = self.client("sk_good")
|
|
194
|
+
a = c.generate_image("a cat", request_key="job-1", wait=False)
|
|
195
|
+
b = c.generate_image("a cat", request_key="job-1", wait=False)
|
|
196
|
+
self.assertEqual(a.id, b.id)
|
|
197
|
+
self.assertEqual(len(FAKE.runs), 1)
|
|
198
|
+
|
|
199
|
+
def test_video_returns_at_once_then_wait(self):
|
|
200
|
+
c = self.client("sk_good")
|
|
201
|
+
FAKE.polls_until_done = 3
|
|
202
|
+
g = c.generate_video("a fox in snow")
|
|
203
|
+
self.assertEqual(g.status, "PROCESSING")
|
|
204
|
+
self.assertTrue(c.wait(g.id, timeout=60).succeeded)
|
|
205
|
+
|
|
206
|
+
def test_wait_timeout_returns_last_state(self):
|
|
207
|
+
c = self.client("sk_good")
|
|
208
|
+
FAKE.polls_until_done = 10_000
|
|
209
|
+
g = c.generate_video("a fox")
|
|
210
|
+
clock = iter(range(0, 10_000, 30))
|
|
211
|
+
with mock.patch("tanvo._client.time.monotonic", lambda: next(clock)):
|
|
212
|
+
last = c.wait(g.id, timeout=60)
|
|
213
|
+
self.assertEqual(last.status, "PROCESSING")
|
|
214
|
+
|
|
215
|
+
def test_errors_map_to_classes(self):
|
|
216
|
+
with self.assertRaises(AuthError) as e:
|
|
217
|
+
self.client("sk_bad").me()
|
|
218
|
+
self.assertEqual(e.exception.code, "invalid_api_key")
|
|
219
|
+
with self.assertRaises(InvalidRequest) as e:
|
|
220
|
+
self.client("sk_good").generate_image("x", resolution="8K")
|
|
221
|
+
self.assertEqual(e.exception.issues, ["options.resolution: 8K"])
|
|
222
|
+
|
|
223
|
+
def test_service_errors_retry_with_the_same_request_key(self):
|
|
224
|
+
c = self.client("sk_good")
|
|
225
|
+
c.models()
|
|
226
|
+
FAKE.fail_next = [(502, "provider", None), (500, "server_error", None)]
|
|
227
|
+
g = c.generate_image("a cat", wait=False)
|
|
228
|
+
posts = [json.loads(r[3])["requestKey"] for r in FAKE.requests if r[0] == "POST" and r[1] == "/api/v1/generations"]
|
|
229
|
+
self.assertEqual(len(posts), 3)
|
|
230
|
+
self.assertEqual(len(set(posts)), 1)
|
|
231
|
+
self.assertEqual(g.status, "PROCESSING")
|
|
232
|
+
|
|
233
|
+
def test_service_error_gives_up_after_two_retries(self):
|
|
234
|
+
c = self.client("sk_good")
|
|
235
|
+
c.models()
|
|
236
|
+
FAKE.fail_next = [(502, "provider", None)] * 3
|
|
237
|
+
with self.assertRaises(ServiceError):
|
|
238
|
+
c.generate_image("a cat", wait=False)
|
|
239
|
+
|
|
240
|
+
def test_rate_limit_waits_once(self):
|
|
241
|
+
c = self.client("sk_good")
|
|
242
|
+
c.models()
|
|
243
|
+
FAKE.fail_next = [(429, "rate_limited", {"Retry-After": "2"})]
|
|
244
|
+
self.assertEqual(c.me().tier, "paid")
|
|
245
|
+
|
|
246
|
+
def test_busy_waits_for_a_slot_when_asked(self):
|
|
247
|
+
c = self.client("sk_good")
|
|
248
|
+
c.models()
|
|
249
|
+
FAKE.fail_next = [(409, "busy", None)]
|
|
250
|
+
with self.assertRaises(QuotaError):
|
|
251
|
+
c.generate_image("a cat", wait=False)
|
|
252
|
+
FAKE.fail_next = [(409, "busy", None)]
|
|
253
|
+
self.assertEqual(c.generate_image("a cat", wait=False, wait_for_slot=True).status, "PROCESSING")
|
|
254
|
+
|
|
255
|
+
def test_local_images_are_uploaded(self):
|
|
256
|
+
c = self.client("sk_good")
|
|
257
|
+
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
|
|
258
|
+
f.write(PNG)
|
|
259
|
+
try:
|
|
260
|
+
g = c.generate_image("make it winter", model="seedream-4", images=[f.name, "https://example.com/a.jpg", PNG], wait=False)
|
|
261
|
+
finally:
|
|
262
|
+
os.unlink(f.name)
|
|
263
|
+
sent = json.loads(next(r[3] for r in FAKE.requests if r[1] == "/api/v1/generations"))
|
|
264
|
+
self.assertEqual(sent["imageUrls"][1], "https://example.com/a.jpg")
|
|
265
|
+
self.assertTrue(all(u.startswith("https://media.tanvo.ai/uploads/") for u in (sent["imageUrls"][0], sent["imageUrls"][2])))
|
|
266
|
+
self.assertEqual([m for m, _ in FAKE.uploaded.values()], ["image/png", "image/png"])
|
|
267
|
+
self.assertEqual(g.status, "PROCESSING")
|
|
268
|
+
|
|
269
|
+
def test_generate_from_app_uses_the_look(self):
|
|
270
|
+
c = self.client("sk_good")
|
|
271
|
+
with self.assertRaises(ValueError):
|
|
272
|
+
c.generate_from_app("ai-pet-portrait-generator", look="royal")
|
|
273
|
+
g = c.generate_from_app("ai-pet-portrait-generator", look="royal", photos=["https://example.com/rex.jpg"], details="Rex, a very good boy")
|
|
274
|
+
self.assertTrue(g.succeeded)
|
|
275
|
+
sent = json.loads(next(r[3] for r in FAKE.requests if r[1] == "/api/v1/generations"))
|
|
276
|
+
self.assertEqual(sent["model"], "seedream-4")
|
|
277
|
+
self.assertEqual(sent["options"], {"aspect": "3:4", "resolution": "2K"})
|
|
278
|
+
self.assertTrue(sent["prompt"].startswith("Paint the pet as a monarch.") and sent["prompt"].endswith("This one is for: Rex, a very good boy."))
|
|
279
|
+
with self.assertRaises(KeyError):
|
|
280
|
+
c.app("ai-pet-portrait-generator").look("nope")
|
|
281
|
+
|
|
282
|
+
def test_list_and_download(self):
|
|
283
|
+
c = self.client("sk_good")
|
|
284
|
+
for i in range(3):
|
|
285
|
+
c.generate_image(f"cat {i}", wait=False)
|
|
286
|
+
items, nxt = c.list(limit=2)
|
|
287
|
+
self.assertEqual(len(items), 2)
|
|
288
|
+
self.assertEqual(nxt, items[-1].created_at)
|
|
289
|
+
g = c.wait(items[0].id)
|
|
290
|
+
with tempfile.TemporaryDirectory() as d:
|
|
291
|
+
path = c.download(g, d)
|
|
292
|
+
with open(path, "rb") as f:
|
|
293
|
+
self.assertEqual(f.read(), PNG)
|
|
294
|
+
|
|
295
|
+
def test_network_error_is_a_service_error(self):
|
|
296
|
+
with self.assertRaises(ServiceError) as e:
|
|
297
|
+
Client("sk_good", base_url="http://127.0.0.1:9").me()
|
|
298
|
+
self.assertEqual(e.exception.code, "network")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class WebhookTest(unittest.TestCase):
|
|
302
|
+
secret = "whsec_" + base64.b64encode(b"k" * 24).decode()
|
|
303
|
+
body = json.dumps({"id": "gen1", "kind": "image", "model": "studio-image-v1", "prompt": "p", "options": {}, "cost": 5, "status": "SUCCEEDED", "error": None, "createdAt": 1, "outputs": [{"url": "https://x/0.png", "mime": "image/png"}]})
|
|
304
|
+
|
|
305
|
+
def sign(self, ts, body=None):
|
|
306
|
+
mac = hmac.new(b"k" * 24, f"msg_1.{ts}.{body or self.body}".encode(), hashlib.sha256).digest()
|
|
307
|
+
return {"webhook-id": "msg_1", "webhook-timestamp": str(ts), "webhook-signature": f"v1,{base64.b64encode(mac).decode()}"}
|
|
308
|
+
|
|
309
|
+
def test_valid(self):
|
|
310
|
+
g = verify_webhook(self.secret, self.sign(int(time.time())), self.body.encode())
|
|
311
|
+
self.assertEqual((g.id, g.url), ("gen1", "https://x/0.png"))
|
|
312
|
+
self.assertEqual(Client.verify_webhook(self.secret, {k.title(): v for k, v in self.sign(int(time.time())).items()}, self.body).id, "gen1")
|
|
313
|
+
|
|
314
|
+
def test_tampered_or_stale(self):
|
|
315
|
+
h = self.sign(int(time.time()))
|
|
316
|
+
with self.assertRaises(WebhookVerificationError):
|
|
317
|
+
verify_webhook(self.secret, h, self.body.replace("gen1", "gen2"))
|
|
318
|
+
with self.assertRaises(WebhookVerificationError):
|
|
319
|
+
verify_webhook(self.secret, self.sign(int(time.time()) - 600), self.body)
|
|
320
|
+
with self.assertRaises(WebhookVerificationError):
|
|
321
|
+
verify_webhook(self.secret, {}, self.body)
|
|
322
|
+
|
|
323
|
+
def test_version(self):
|
|
324
|
+
self.assertRegex(tanvo.__version__, r"^\d+\.\d+\.\d+$")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == "__main__":
|
|
328
|
+
unittest.main()
|