runapi-imagen-4 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.
- runapi_imagen_4-0.1.0/.gitignore +29 -0
- runapi_imagen_4-0.1.0/PKG-INFO +85 -0
- runapi_imagen_4-0.1.0/README.md +72 -0
- runapi_imagen_4-0.1.0/pyproject.toml +30 -0
- runapi_imagen_4-0.1.0/src/runapi/imagen_4/__init__.py +24 -0
- runapi_imagen_4-0.1.0/src/runapi/imagen_4/client.py +29 -0
- runapi_imagen_4-0.1.0/src/runapi/imagen_4/py.typed +0 -0
- runapi_imagen_4-0.1.0/src/runapi/imagen_4/resources/__init__.py +4 -0
- runapi_imagen_4-0.1.0/src/runapi/imagen_4/resources/remix_image.py +84 -0
- runapi_imagen_4-0.1.0/src/runapi/imagen_4/resources/text_to_image.py +81 -0
- runapi_imagen_4-0.1.0/src/runapi/imagen_4/types.py +46 -0
- runapi_imagen_4-0.1.0/tests/test_client.py +210 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Build artifacts
|
|
2
|
+
dist/
|
|
3
|
+
build/
|
|
4
|
+
*.egg-info/
|
|
5
|
+
*.egg
|
|
6
|
+
|
|
7
|
+
# Bytecode
|
|
8
|
+
__pycache__/
|
|
9
|
+
*.py[cod]
|
|
10
|
+
|
|
11
|
+
# Virtual environments
|
|
12
|
+
.venv/
|
|
13
|
+
venv/
|
|
14
|
+
|
|
15
|
+
# uv
|
|
16
|
+
uv.lock
|
|
17
|
+
|
|
18
|
+
# Test / type caches
|
|
19
|
+
.pytest_cache/
|
|
20
|
+
.mypy_cache/
|
|
21
|
+
.ruff_cache/
|
|
22
|
+
.coverage
|
|
23
|
+
htmlcov/
|
|
24
|
+
|
|
25
|
+
# IDE / OS
|
|
26
|
+
.idea/
|
|
27
|
+
.vscode/
|
|
28
|
+
*.swp
|
|
29
|
+
.DS_Store
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runapi-imagen-4
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Imagen 4 text-to-image and remix-image client for RunAPI
|
|
5
|
+
Project-URL: Homepage, https://runapi.ai/models/imagen-4
|
|
6
|
+
Project-URL: Documentation, https://runapi.ai/docs#sdk-imagen-4
|
|
7
|
+
Author-email: RunAPI <contact@runapi.ai>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Keywords: ai,imagen-4,remix-image,runapi,sdk,text-to-image
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Requires-Dist: runapi-core
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# Imagen 4 Python SDK for RunAPI
|
|
15
|
+
|
|
16
|
+
The Imagen 4 Python SDK is the language-specific package for Imagen 4 on RunAPI.
|
|
17
|
+
Use it for text-to-image and remix-image flows when your application needs JSON
|
|
18
|
+
request bodies, task status lookup, and consistent RunAPI errors in Python.
|
|
19
|
+
|
|
20
|
+
For model details, use https://runapi.ai/models/imagen-4; for API reference, use
|
|
21
|
+
https://runapi.ai/docs#imagen-4; for SDK docs, use https://runapi.ai/docs#sdk-imagen-4.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install runapi-imagen-4
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from runapi.imagen_4 import Imagen4Client
|
|
33
|
+
|
|
34
|
+
client = Imagen4Client() # reads RUNAPI_API_KEY, or pass api_key="sk-..."
|
|
35
|
+
|
|
36
|
+
task = client.text_to_image.create(
|
|
37
|
+
model="imagen-4",
|
|
38
|
+
prompt="A neon city street after rain, cinematic",
|
|
39
|
+
aspect_ratio="16:9",
|
|
40
|
+
)
|
|
41
|
+
status = client.text_to_image.get(task.id)
|
|
42
|
+
|
|
43
|
+
remix = client.remix_image.create(
|
|
44
|
+
model="imagen-4-pro-remix-image",
|
|
45
|
+
prompt="Make it golden hour",
|
|
46
|
+
source_image_urls=["https://example.com/source.jpg"],
|
|
47
|
+
)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Use `create` to submit a task and return quickly, `get` to fetch the latest task
|
|
51
|
+
state, and `run` to create and poll until completion:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
result = client.text_to_image.run(
|
|
55
|
+
model="imagen-4",
|
|
56
|
+
prompt="A serene mountain lake at dawn",
|
|
57
|
+
)
|
|
58
|
+
print(result.images[0].url)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
In web request handlers, prefer `create` plus webhook or later `get` polling so a
|
|
62
|
+
worker is not held open.
|
|
63
|
+
|
|
64
|
+
RunAPI-generated file URLs are temporary. Download and store generated images in
|
|
65
|
+
your own durable storage within 7 days; do not treat returned URLs as long-term
|
|
66
|
+
assets.
|
|
67
|
+
|
|
68
|
+
## Language notes
|
|
69
|
+
|
|
70
|
+
Pass parameters as keyword arguments and catch the `runapi.imagen_4` error
|
|
71
|
+
classes when building image jobs or scripts. The available resources are
|
|
72
|
+
`text_to_image` and `remix_image`. Keep `RUNAPI_API_KEY` in the environment or
|
|
73
|
+
your secret manager; never commit API keys or callback secrets.
|
|
74
|
+
|
|
75
|
+
## Links
|
|
76
|
+
|
|
77
|
+
- Model page: https://runapi.ai/models/imagen-4
|
|
78
|
+
- SDK docs: https://runapi.ai/docs#sdk-imagen-4
|
|
79
|
+
- Product docs: https://runapi.ai/docs#imagen-4
|
|
80
|
+
- Pricing and rate limits: https://runapi.ai/models/imagen-4
|
|
81
|
+
- Full catalog: https://runapi.ai/models
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
Licensed under the Apache License, Version 2.0.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Imagen 4 Python SDK for RunAPI
|
|
2
|
+
|
|
3
|
+
The Imagen 4 Python SDK is the language-specific package for Imagen 4 on RunAPI.
|
|
4
|
+
Use it for text-to-image and remix-image flows when your application needs JSON
|
|
5
|
+
request bodies, task status lookup, and consistent RunAPI errors in Python.
|
|
6
|
+
|
|
7
|
+
For model details, use https://runapi.ai/models/imagen-4; for API reference, use
|
|
8
|
+
https://runapi.ai/docs#imagen-4; for SDK docs, use https://runapi.ai/docs#sdk-imagen-4.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install runapi-imagen-4
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from runapi.imagen_4 import Imagen4Client
|
|
20
|
+
|
|
21
|
+
client = Imagen4Client() # reads RUNAPI_API_KEY, or pass api_key="sk-..."
|
|
22
|
+
|
|
23
|
+
task = client.text_to_image.create(
|
|
24
|
+
model="imagen-4",
|
|
25
|
+
prompt="A neon city street after rain, cinematic",
|
|
26
|
+
aspect_ratio="16:9",
|
|
27
|
+
)
|
|
28
|
+
status = client.text_to_image.get(task.id)
|
|
29
|
+
|
|
30
|
+
remix = client.remix_image.create(
|
|
31
|
+
model="imagen-4-pro-remix-image",
|
|
32
|
+
prompt="Make it golden hour",
|
|
33
|
+
source_image_urls=["https://example.com/source.jpg"],
|
|
34
|
+
)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Use `create` to submit a task and return quickly, `get` to fetch the latest task
|
|
38
|
+
state, and `run` to create and poll until completion:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
result = client.text_to_image.run(
|
|
42
|
+
model="imagen-4",
|
|
43
|
+
prompt="A serene mountain lake at dawn",
|
|
44
|
+
)
|
|
45
|
+
print(result.images[0].url)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
In web request handlers, prefer `create` plus webhook or later `get` polling so a
|
|
49
|
+
worker is not held open.
|
|
50
|
+
|
|
51
|
+
RunAPI-generated file URLs are temporary. Download and store generated images in
|
|
52
|
+
your own durable storage within 7 days; do not treat returned URLs as long-term
|
|
53
|
+
assets.
|
|
54
|
+
|
|
55
|
+
## Language notes
|
|
56
|
+
|
|
57
|
+
Pass parameters as keyword arguments and catch the `runapi.imagen_4` error
|
|
58
|
+
classes when building image jobs or scripts. The available resources are
|
|
59
|
+
`text_to_image` and `remix_image`. Keep `RUNAPI_API_KEY` in the environment or
|
|
60
|
+
your secret manager; never commit API keys or callback secrets.
|
|
61
|
+
|
|
62
|
+
## Links
|
|
63
|
+
|
|
64
|
+
- Model page: https://runapi.ai/models/imagen-4
|
|
65
|
+
- SDK docs: https://runapi.ai/docs#sdk-imagen-4
|
|
66
|
+
- Product docs: https://runapi.ai/docs#imagen-4
|
|
67
|
+
- Pricing and rate limits: https://runapi.ai/models/imagen-4
|
|
68
|
+
- Full catalog: https://runapi.ai/models
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
Licensed under the Apache License, Version 2.0.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "runapi-imagen-4"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Imagen 4 text-to-image and remix-image client for RunAPI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
authors = [{ name = "RunAPI", email = "contact@runapi.ai" }]
|
|
13
|
+
keywords = ["runapi", "imagen-4", "text-to-image", "remix-image", "ai", "sdk"]
|
|
14
|
+
dependencies = ["runapi-core"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://runapi.ai/models/imagen-4"
|
|
18
|
+
Documentation = "https://runapi.ai/docs#sdk-imagen-4"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["src/runapi"]
|
|
22
|
+
|
|
23
|
+
[tool.uv]
|
|
24
|
+
package = true
|
|
25
|
+
|
|
26
|
+
[tool.uv.sources]
|
|
27
|
+
runapi-core = { path = "../runapi-core", editable = true }
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = ["pytest>=8"]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Imagen 4 client for RunAPI."""
|
|
2
|
+
|
|
3
|
+
from runapi.core import (
|
|
4
|
+
AuthenticationError,
|
|
5
|
+
InsufficientCreditsError,
|
|
6
|
+
NotFoundError,
|
|
7
|
+
RateLimitError,
|
|
8
|
+
TaskFailedError,
|
|
9
|
+
TaskTimeoutError,
|
|
10
|
+
ValidationError,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from .client import Imagen4Client
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Imagen4Client",
|
|
17
|
+
"AuthenticationError",
|
|
18
|
+
"RateLimitError",
|
|
19
|
+
"InsufficientCreditsError",
|
|
20
|
+
"NotFoundError",
|
|
21
|
+
"ValidationError",
|
|
22
|
+
"TaskFailedError",
|
|
23
|
+
"TaskTimeoutError",
|
|
24
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Imagen 4 client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from runapi.core import ClientOptions, HttpClient, resolve_api_key
|
|
8
|
+
|
|
9
|
+
from .resources.remix_image import RemixImage
|
|
10
|
+
from .resources.text_to_image import TextToImage
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Imagen4Client:
|
|
14
|
+
"""Imagen 4 text-to-image and remix-image client.
|
|
15
|
+
|
|
16
|
+
Example::
|
|
17
|
+
|
|
18
|
+
client = Imagen4Client(api_key="sk-...")
|
|
19
|
+
result = client.text_to_image.run(
|
|
20
|
+
model="imagen-4", prompt="A neon city street"
|
|
21
|
+
)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, api_key: Optional[str] = None, **options: Any) -> None:
|
|
25
|
+
resolved_api_key = resolve_api_key(api_key)
|
|
26
|
+
client_options = ClientOptions(api_key=resolved_api_key, **options)
|
|
27
|
+
http = client_options.http_client or HttpClient(client_options)
|
|
28
|
+
self.text_to_image = TextToImage(http)
|
|
29
|
+
self.remix_image = RemixImage(http)
|
|
File without changes
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Imagen 4 remix-image resource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
from runapi.core import Resource, ValidationError
|
|
8
|
+
|
|
9
|
+
from ..types import (
|
|
10
|
+
OUTPUT_FORMATS,
|
|
11
|
+
OUTPUT_RESOLUTIONS,
|
|
12
|
+
PRO_ASPECT_RATIOS,
|
|
13
|
+
REMIX_MODELS,
|
|
14
|
+
CompletedRemixImageResponse,
|
|
15
|
+
RemixImageResponse,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RemixImage(Resource):
|
|
20
|
+
"""Remix a set of source images with an Imagen 4 pro model."""
|
|
21
|
+
|
|
22
|
+
ENDPOINT = "/api/v1/imagen_4/remix_image"
|
|
23
|
+
|
|
24
|
+
RESPONSE_CLASS = RemixImageResponse
|
|
25
|
+
COMPLETED_RESPONSE_CLASS = CompletedRemixImageResponse
|
|
26
|
+
|
|
27
|
+
SOURCE_IMAGE_URLS_MAX = 8
|
|
28
|
+
|
|
29
|
+
def run(self, **params: Any) -> Any:
|
|
30
|
+
"""Remix source images and poll until it completes.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
**params: Remix-image parameters (model, prompt, source_image_urls, ...).
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
The completed task with images.
|
|
37
|
+
"""
|
|
38
|
+
task = self.create(**params)
|
|
39
|
+
return self._poll_until_complete(lambda: self.get(task.id))
|
|
40
|
+
|
|
41
|
+
def create(self, **params: Any) -> Any:
|
|
42
|
+
"""Create a remix-image task and return immediately with an ``id``.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
**params: Remix-image parameters (model, prompt, source_image_urls, ...).
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
The task creation result with an id.
|
|
49
|
+
"""
|
|
50
|
+
compacted = self._compact_params(params)
|
|
51
|
+
self._validate_params(compacted)
|
|
52
|
+
return self._request("post", self.ENDPOINT, body=compacted)
|
|
53
|
+
|
|
54
|
+
def get(self, id: str) -> Any:
|
|
55
|
+
"""Fetch the current status of a remix-image task.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
id: The task id.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The current task status.
|
|
62
|
+
"""
|
|
63
|
+
return self._request("get", f"{self.ENDPOINT}/{id}")
|
|
64
|
+
|
|
65
|
+
def _validate_params(self, params: Dict[str, Any]) -> None:
|
|
66
|
+
model = params.get("model")
|
|
67
|
+
if not model:
|
|
68
|
+
raise ValidationError("model is required")
|
|
69
|
+
if model not in REMIX_MODELS:
|
|
70
|
+
raise ValidationError(f"Invalid model: {model}. Must be one of: {', '.join(REMIX_MODELS)}")
|
|
71
|
+
if not params.get("prompt"):
|
|
72
|
+
raise ValidationError("prompt is required")
|
|
73
|
+
|
|
74
|
+
self._validate_source_image_urls(params)
|
|
75
|
+
self._validate_optional(params, "aspect_ratio", PRO_ASPECT_RATIOS)
|
|
76
|
+
self._validate_optional(params, "output_resolution", OUTPUT_RESOLUTIONS)
|
|
77
|
+
self._validate_optional(params, "output_format", OUTPUT_FORMATS)
|
|
78
|
+
|
|
79
|
+
def _validate_source_image_urls(self, params: Dict[str, Any]) -> None:
|
|
80
|
+
urls = params.get("source_image_urls")
|
|
81
|
+
if urls is None or (hasattr(urls, "__len__") and len(urls) == 0):
|
|
82
|
+
raise ValidationError("source_image_urls is required")
|
|
83
|
+
if hasattr(urls, "__len__") and len(urls) > self.SOURCE_IMAGE_URLS_MAX:
|
|
84
|
+
raise ValidationError(f"source_image_urls supports up to {self.SOURCE_IMAGE_URLS_MAX} images")
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Imagen 4 text-to-image resource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
from runapi.core import Resource, ValidationError
|
|
8
|
+
|
|
9
|
+
from ..types import (
|
|
10
|
+
MODELS,
|
|
11
|
+
OUTPUT_COUNTS,
|
|
12
|
+
TEXT_ASPECT_RATIOS,
|
|
13
|
+
CompletedTextToImageResponse,
|
|
14
|
+
TextToImageResponse,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TextToImage(Resource):
|
|
19
|
+
"""Generate images from text prompts with Imagen 4 models."""
|
|
20
|
+
|
|
21
|
+
ENDPOINT = "/api/v1/imagen_4/text_to_image"
|
|
22
|
+
|
|
23
|
+
RESPONSE_CLASS = TextToImageResponse
|
|
24
|
+
COMPLETED_RESPONSE_CLASS = CompletedTextToImageResponse
|
|
25
|
+
|
|
26
|
+
def run(self, **params: Any) -> Any:
|
|
27
|
+
"""Generate images from text and poll until it completes.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
**params: Text-to-image parameters (model, prompt, ...).
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
The completed task with images.
|
|
34
|
+
"""
|
|
35
|
+
task = self.create(**params)
|
|
36
|
+
return self._poll_until_complete(lambda: self.get(task.id))
|
|
37
|
+
|
|
38
|
+
def create(self, **params: Any) -> Any:
|
|
39
|
+
"""Create a text-to-image task and return immediately with an ``id``.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
**params: Text-to-image parameters (model, prompt, ...).
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The task creation result with an id.
|
|
46
|
+
"""
|
|
47
|
+
compacted = self._compact_params(params)
|
|
48
|
+
self._validate_params(compacted)
|
|
49
|
+
return self._request("post", self.ENDPOINT, body=compacted)
|
|
50
|
+
|
|
51
|
+
def get(self, id: str) -> Any:
|
|
52
|
+
"""Fetch the current status of a text-to-image task.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
id: The task id.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The current task status.
|
|
59
|
+
"""
|
|
60
|
+
return self._request("get", f"{self.ENDPOINT}/{id}")
|
|
61
|
+
|
|
62
|
+
def _validate_params(self, params: Dict[str, Any]) -> None:
|
|
63
|
+
if not params.get("model"):
|
|
64
|
+
raise ValidationError("model is required")
|
|
65
|
+
if not params.get("prompt"):
|
|
66
|
+
raise ValidationError("prompt is required")
|
|
67
|
+
|
|
68
|
+
model = params.get("model")
|
|
69
|
+
if model not in MODELS:
|
|
70
|
+
raise ValidationError(f"Invalid model: {model}. Must be: {', '.join(MODELS)}")
|
|
71
|
+
|
|
72
|
+
self._validate_text_params(params, model)
|
|
73
|
+
|
|
74
|
+
def _validate_text_params(self, params: Dict[str, Any], model: str) -> None:
|
|
75
|
+
self._validate_optional(params, "aspect_ratio", TEXT_ASPECT_RATIOS)
|
|
76
|
+
if not params.get("output_count"):
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
if model != "imagen-4-fast":
|
|
80
|
+
raise ValidationError("output_count is only supported for imagen-4-fast")
|
|
81
|
+
self._validate_optional(params, "output_count", OUTPUT_COUNTS)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Imagen 4 model lists, enums, and response models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from runapi.core import BaseModel, TaskResponse, optional, required
|
|
6
|
+
|
|
7
|
+
TEXT_MODELS = ["imagen-4", "imagen-4-fast", "imagen-4-ultra"]
|
|
8
|
+
REMIX_MODELS = ["imagen-4-pro-remix-image"]
|
|
9
|
+
MODELS = TEXT_MODELS + REMIX_MODELS
|
|
10
|
+
TEXT_ASPECT_RATIOS = ["1:1", "16:9", "9:16", "3:4", "4:3"]
|
|
11
|
+
PRO_ASPECT_RATIOS = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9", "auto"]
|
|
12
|
+
OUTPUT_RESOLUTIONS = ["1k", "2k", "4k"]
|
|
13
|
+
OUTPUT_FORMATS = ["png", "jpg"]
|
|
14
|
+
OUTPUT_COUNTS = [1, 2, 3, 4]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Image(BaseModel):
|
|
18
|
+
url = optional(str)
|
|
19
|
+
origin_url = optional(str)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TextToImageResponse(TaskResponse):
|
|
23
|
+
"""Response for a text-to-image task."""
|
|
24
|
+
|
|
25
|
+
id = required(str)
|
|
26
|
+
status = optional(str, enum=lambda: TaskResponse.Status.ALL)
|
|
27
|
+
images = optional([lambda: Image])
|
|
28
|
+
error = optional(str)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CompletedTextToImageResponse(TextToImageResponse):
|
|
32
|
+
"""Narrowed response from ``run()`` once polling observes completion."""
|
|
33
|
+
|
|
34
|
+
images = required([lambda: Image])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RemixImageResponse(TextToImageResponse):
|
|
38
|
+
"""Response for a remix-image task."""
|
|
39
|
+
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CompletedRemixImageResponse(CompletedTextToImageResponse):
|
|
44
|
+
"""Narrowed remix-image response from ``run()`` once polling observes completion."""
|
|
45
|
+
|
|
46
|
+
pass
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from runapi.core import config
|
|
4
|
+
from runapi.core.errors import AuthenticationError, ValidationError
|
|
5
|
+
from runapi.imagen_4 import Imagen4Client
|
|
6
|
+
from runapi.imagen_4.resources.remix_image import RemixImage
|
|
7
|
+
from runapi.imagen_4.resources.text_to_image import TextToImage
|
|
8
|
+
from runapi.imagen_4.types import (
|
|
9
|
+
CompletedRemixImageResponse,
|
|
10
|
+
CompletedTextToImageResponse,
|
|
11
|
+
RemixImageResponse,
|
|
12
|
+
TextToImageResponse,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FakeHttp:
|
|
17
|
+
def __init__(self, *responses):
|
|
18
|
+
self._responses = list(responses)
|
|
19
|
+
self.calls = []
|
|
20
|
+
|
|
21
|
+
def request(self, method, path, body=None, options=None):
|
|
22
|
+
self.calls.append((method, path, body))
|
|
23
|
+
if self._responses:
|
|
24
|
+
return self._responses.pop(0)
|
|
25
|
+
return {"id": "task_1", "status": "pending"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture(autouse=True)
|
|
29
|
+
def reset_config(monkeypatch):
|
|
30
|
+
monkeypatch.delenv("RUNAPI_API_KEY", raising=False)
|
|
31
|
+
monkeypatch.setattr(config, "api_key", None)
|
|
32
|
+
yield
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# --- auth -----------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_accepts_api_key_parameter():
|
|
39
|
+
assert isinstance(Imagen4Client(api_key="k", http_client=FakeHttp()), Imagen4Client)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_falls_back_to_global(monkeypatch):
|
|
43
|
+
monkeypatch.setattr(config, "api_key", "global-key")
|
|
44
|
+
assert isinstance(Imagen4Client(http_client=FakeHttp()), Imagen4Client)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_falls_back_to_env(monkeypatch):
|
|
48
|
+
monkeypatch.setenv("RUNAPI_API_KEY", "env-key")
|
|
49
|
+
assert isinstance(Imagen4Client(http_client=FakeHttp()), Imagen4Client)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_raises_without_api_key():
|
|
53
|
+
with pytest.raises(AuthenticationError, match="API key is required"):
|
|
54
|
+
Imagen4Client()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --- injection / accessors ------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_uses_injected_http_client():
|
|
61
|
+
fake = FakeHttp()
|
|
62
|
+
client = Imagen4Client(api_key="k", http_client=fake)
|
|
63
|
+
assert client.text_to_image._http is fake
|
|
64
|
+
assert client.remix_image._http is fake
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_exposes_resource_accessors():
|
|
68
|
+
client = Imagen4Client(api_key="k", http_client=FakeHttp())
|
|
69
|
+
assert isinstance(client.text_to_image, TextToImage)
|
|
70
|
+
assert isinstance(client.remix_image, RemixImage)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# --- request shapes -------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_create_posts_compacted_body():
|
|
77
|
+
fake = FakeHttp({"id": "t1", "status": "pending"})
|
|
78
|
+
client = Imagen4Client(api_key="k", http_client=fake)
|
|
79
|
+
result = client.text_to_image.create(
|
|
80
|
+
model="imagen-4", prompt="hello world", aspect_ratio="1:1", output_count=None
|
|
81
|
+
)
|
|
82
|
+
assert fake.calls == [
|
|
83
|
+
("post", "/api/v1/imagen_4/text_to_image", {"model": "imagen-4", "prompt": "hello world", "aspect_ratio": "1:1"}),
|
|
84
|
+
]
|
|
85
|
+
assert isinstance(result, TextToImageResponse)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_get_fetches_by_id():
|
|
89
|
+
fake = FakeHttp({"id": "t1", "status": "processing"})
|
|
90
|
+
client = Imagen4Client(api_key="k", http_client=fake)
|
|
91
|
+
client.text_to_image.get("t1")
|
|
92
|
+
assert fake.calls == [("get", "/api/v1/imagen_4/text_to_image/t1", None)]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_remix_create_posts_compacted_body():
|
|
96
|
+
fake = FakeHttp({"id": "t1", "status": "pending"})
|
|
97
|
+
client = Imagen4Client(api_key="k", http_client=fake)
|
|
98
|
+
result = client.remix_image.create(
|
|
99
|
+
model="imagen-4-pro-remix-image",
|
|
100
|
+
prompt="golden hour",
|
|
101
|
+
source_image_urls=["https://x/a.png"],
|
|
102
|
+
output_resolution="2k",
|
|
103
|
+
)
|
|
104
|
+
assert fake.calls == [
|
|
105
|
+
(
|
|
106
|
+
"post",
|
|
107
|
+
"/api/v1/imagen_4/remix_image",
|
|
108
|
+
{
|
|
109
|
+
"model": "imagen-4-pro-remix-image",
|
|
110
|
+
"prompt": "golden hour",
|
|
111
|
+
"source_image_urls": ["https://x/a.png"],
|
|
112
|
+
"output_resolution": "2k",
|
|
113
|
+
},
|
|
114
|
+
),
|
|
115
|
+
]
|
|
116
|
+
assert isinstance(result, RemixImageResponse)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_run_narrows_completed_type():
|
|
120
|
+
fake = FakeHttp(
|
|
121
|
+
{"id": "t1", "status": "pending"},
|
|
122
|
+
{"id": "t1", "status": "completed", "images": [{"url": "https://x/y.png"}]},
|
|
123
|
+
)
|
|
124
|
+
client = Imagen4Client(api_key="k", http_client=fake)
|
|
125
|
+
result = client.text_to_image.run(model="imagen-4", prompt="a serene lake")
|
|
126
|
+
assert isinstance(result, CompletedTextToImageResponse)
|
|
127
|
+
assert result.images[0].url == "https://x/y.png"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_remix_run_narrows_completed_type():
|
|
131
|
+
fake = FakeHttp(
|
|
132
|
+
{"id": "t1", "status": "pending"},
|
|
133
|
+
{"id": "t1", "status": "completed", "images": [{"url": "https://x/y.png"}]},
|
|
134
|
+
)
|
|
135
|
+
client = Imagen4Client(api_key="k", http_client=fake)
|
|
136
|
+
result = client.remix_image.run(
|
|
137
|
+
model="imagen-4-pro-remix-image",
|
|
138
|
+
prompt="golden hour",
|
|
139
|
+
source_image_urls=["https://x/a.png"],
|
|
140
|
+
)
|
|
141
|
+
assert isinstance(result, CompletedRemixImageResponse)
|
|
142
|
+
assert result.images[0].url == "https://x/y.png"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# --- validation -----------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_rejects_unknown_model():
|
|
149
|
+
client = Imagen4Client(api_key="k", http_client=FakeHttp())
|
|
150
|
+
with pytest.raises(ValidationError, match="Invalid model: nope. Must be:"):
|
|
151
|
+
client.text_to_image.create(model="nope", prompt="hi there")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_requires_model():
|
|
155
|
+
client = Imagen4Client(api_key="k", http_client=FakeHttp())
|
|
156
|
+
with pytest.raises(ValidationError, match="model is required"):
|
|
157
|
+
client.text_to_image.create(prompt="hi there")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_requires_prompt():
|
|
161
|
+
client = Imagen4Client(api_key="k", http_client=FakeHttp())
|
|
162
|
+
with pytest.raises(ValidationError, match="prompt is required"):
|
|
163
|
+
client.text_to_image.create(model="imagen-4")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_rejects_invalid_aspect_ratio():
|
|
167
|
+
client = Imagen4Client(api_key="k", http_client=FakeHttp())
|
|
168
|
+
with pytest.raises(ValidationError, match="aspect_ratio"):
|
|
169
|
+
client.text_to_image.create(model="imagen-4", prompt="hi there", aspect_ratio="2:3")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_output_count_only_for_fast():
|
|
173
|
+
client = Imagen4Client(api_key="k", http_client=FakeHttp())
|
|
174
|
+
with pytest.raises(ValidationError, match="output_count is only supported for imagen-4-fast"):
|
|
175
|
+
client.text_to_image.create(model="imagen-4", prompt="hi there", output_count=2)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_output_count_allowed_for_fast():
|
|
179
|
+
fake = FakeHttp({"id": "t1", "status": "pending"})
|
|
180
|
+
client = Imagen4Client(api_key="k", http_client=fake)
|
|
181
|
+
client.text_to_image.create(model="imagen-4-fast", prompt="hi there", output_count=2)
|
|
182
|
+
assert fake.calls[0][2]["output_count"] == 2
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_output_count_rejects_out_of_range_for_fast():
|
|
186
|
+
client = Imagen4Client(api_key="k", http_client=FakeHttp())
|
|
187
|
+
with pytest.raises(ValidationError, match="output_count"):
|
|
188
|
+
client.text_to_image.create(model="imagen-4-fast", prompt="hi there", output_count=9)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_remix_rejects_non_remix_model():
|
|
192
|
+
client = Imagen4Client(api_key="k", http_client=FakeHttp())
|
|
193
|
+
with pytest.raises(ValidationError, match="Invalid model: imagen-4. Must be one of:"):
|
|
194
|
+
client.remix_image.create(model="imagen-4", prompt="hi", source_image_urls=["https://x/a.png"])
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_remix_requires_source_image_urls():
|
|
198
|
+
client = Imagen4Client(api_key="k", http_client=FakeHttp())
|
|
199
|
+
with pytest.raises(ValidationError, match="source_image_urls is required"):
|
|
200
|
+
client.remix_image.create(model="imagen-4-pro-remix-image", prompt="hi")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_remix_source_image_urls_max():
|
|
204
|
+
client = Imagen4Client(api_key="k", http_client=FakeHttp())
|
|
205
|
+
with pytest.raises(ValidationError, match="source_image_urls supports up to 8 images"):
|
|
206
|
+
client.remix_image.create(
|
|
207
|
+
model="imagen-4-pro-remix-image",
|
|
208
|
+
prompt="hi",
|
|
209
|
+
source_image_urls=[f"https://x/{i}.png" for i in range(9)],
|
|
210
|
+
)
|