genblaze-google 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+
7
+ # Distribution / packaging
8
+ dist/
9
+ build/
10
+ *.egg-info/
11
+ *.egg
12
+ wheels/
13
+ /MANIFEST
14
+
15
+ # Virtual environments
16
+ .venv/
17
+ venv/
18
+ env/
19
+ ENV/
20
+
21
+ # IDE / editors
22
+ .vscode/
23
+ .idea/
24
+ *.swp
25
+ *.swo
26
+ *~
27
+ .project
28
+ .settings/
29
+
30
+ # Testing / coverage
31
+ .pytest_cache/
32
+ .coverage
33
+ .coverage.*
34
+ htmlcov/
35
+ coverage.xml
36
+ *.cover
37
+
38
+ # Type checking
39
+ .mypy_cache/
40
+ .pytype/
41
+ .pyre/
42
+
43
+ # Linting
44
+ .ruff_cache/
45
+
46
+ # OS files
47
+ .DS_Store
48
+ Thumbs.db
49
+ ehthumbs.db
50
+
51
+ # Environment / secrets
52
+ .env
53
+ .env.*
54
+ !.env.example
55
+ *.pem
56
+ *.key
57
+ credentials.json
58
+
59
+ # Jupyter
60
+ .ipynb_checkpoints/
61
+
62
+ # MkDocs build output
63
+ site/
64
+
65
+ # Claude Code — local/ephemeral
66
+ .claude/settings.local.json
67
+ .claude/worktrees/
68
+ CLAUDE.local.md
69
+
70
+ # Hypothesis test cache
71
+ .hypothesis/
72
+
73
+ # Temp files
74
+ *.tmp
75
+ *.bak
76
+ *.log
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: genblaze-google
3
+ Version: 0.1.0
4
+ Summary: Google provider adapters for genblaze (Veo video, Imagen image)
5
+ Project-URL: Documentation, https://github.com/backblaze-labs/genblaze
6
+ Project-URL: Repository, https://github.com/backblaze-labs/genblaze
7
+ Project-URL: Issues, https://github.com/backblaze-labs/genblaze/issues
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Typing :: Typed
12
+ Requires-Python: >=3.11
13
+ Requires-Dist: genblaze-core<0.2,>=0.1.0
14
+ Requires-Dist: google-genai>=1.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=7.0; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ <!-- last_verified: 2026-04-22 -->
20
+ # genblaze-google
21
+
22
+ **Google provider adapters for [genblaze](https://github.com/backblaze-labs/genblaze) — [Veo](https://deepmind.google/technologies/veo/) text-to-video and [Imagen](https://deepmind.google/technologies/imagen-3/) text-to-image — with SHA-256 provenance manifests on every output.**
23
+
24
+ `genblaze-google` wraps Google's generative media models (Veo 2, Veo 3, Imagen 3) as genblaze providers via the unified `google-genai` SDK. Works with both Gemini API keys and Google Cloud Vertex AI authentication. Compose Veo/Imagen calls into multi-step AI pipelines, persist outputs to [Backblaze B2](https://www.backblaze.com/cloud-storage?utm_source=github&utm_medium=referral&utm_campaign=ai_artifacts&utm_content=genblaze) or any S3-compatible store, and emit a tamper-evident provenance manifest for every run.
25
+
26
+ ## Why genblaze-google
27
+
28
+ - **Veo 3 with synchronized audio** — text-to-video + native audio, wrapped in a provenance manifest.
29
+ - **Imagen 3 high-fidelity images** — photorealistic stills with full parameter tracking.
30
+ - **Two auth modes** — Gemini API (`GEMINI_API_KEY`) for quick start, Vertex AI for enterprise / GCP orgs.
31
+ - **Same SDK, any provider** — swap to Sora, Runway, Luma, Flux without rewriting pipeline logic.
32
+ - **Provenance by default** — SHA-256 hash + canonical manifest on every generation.
33
+ - **Durable storage** — plug `genblaze-s3` in for Backblaze B2 / AWS S3 / Cloudflare R2 / MinIO.
34
+
35
+ ## Providers + models
36
+
37
+ | Provider class | Modality | Models |
38
+ |---|---|---|
39
+ | `VeoProvider` | video | `veo-3.0-generate-001` (with audio), `veo-3.0-fast-generate-001`, `veo-2.0-generate-001` |
40
+ | `ImagenProvider` | image | `imagen-3.0-generate-002`, `imagen-3.0-fast-generate-001` |
41
+
42
+ Each is registered via entry points (`google-veo`, `google-imagen`).
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install genblaze-google
48
+ ```
49
+
50
+ ## Quickstart — Veo 3 text-to-video (with audio)
51
+
52
+ ```bash
53
+ export GEMINI_API_KEY="..." # or use Vertex AI auth
54
+ ```
55
+
56
+ ```python
57
+ from genblaze_core import Modality, Pipeline
58
+ from genblaze_google import VeoProvider
59
+
60
+ run, manifest = (
61
+ Pipeline("veo-demo")
62
+ .step(VeoProvider(), model="veo-3.0-generate-001",
63
+ prompt="A time-lapse of a coral reef coming to life, colorful fish "
64
+ "swimming through vibrant coral, natural ocean lighting",
65
+ modality=Modality.VIDEO,
66
+ aspect_ratio="16:9", duration_seconds="8", resolution="720p",
67
+ enhance_prompt=True)
68
+ .run(timeout=600)
69
+ )
70
+ print(run.steps[0].assets[0].url, manifest.canonical_hash)
71
+ ```
72
+
73
+ Vertex AI auth instead:
74
+
75
+ ```python
76
+ provider = VeoProvider(project="my-gcp-project", location="us-central1")
77
+ ```
78
+
79
+ ## Quickstart — Imagen 3 text-to-image
80
+
81
+ ```python
82
+ from genblaze_google import ImagenProvider
83
+
84
+ run, manifest = (
85
+ Pipeline("imagen-demo")
86
+ .step(ImagenProvider(output_dir="output/images"),
87
+ model="imagen-3.0-generate-002",
88
+ prompt="A photorealistic aerial view of a coral reef teeming with tropical fish",
89
+ modality=Modality.IMAGE, aspect_ratio="16:9")
90
+ .run(timeout=120)
91
+ )
92
+ ```
93
+
94
+ ## Persist to Backblaze B2
95
+
96
+ ```python
97
+ from genblaze_core import KeyStrategy, ObjectStorageSink
98
+ from genblaze_s3 import S3StorageBackend
99
+
100
+ storage = ObjectStorageSink(
101
+ S3StorageBackend.for_backblaze("my-bucket"),
102
+ key_strategy=KeyStrategy.HIERARCHICAL,
103
+ )
104
+ # pass sink=storage to .run(…) to push assets + manifest to B2
105
+ ```
106
+
107
+ [Backblaze B2](https://www.backblaze.com/cloud-storage?utm_source=github&utm_medium=referral&utm_campaign=ai_artifacts&utm_content=genblaze) is the recommended default sink for genblaze — cost-efficient, S3-compatible, with Object Lock for tamper-evident manifests.
108
+
109
+ ## Credentials
110
+
111
+ | Auth mode | Env var / config |
112
+ |---|---|
113
+ | Gemini API (quickest) | `GEMINI_API_KEY` — https://aistudio.google.com/apikey |
114
+ | Vertex AI | `VeoProvider(project=..., location=...)` + `gcloud auth application-default login` |
115
+
116
+ ## Documentation
117
+
118
+ - **Main repo**: https://github.com/backblaze-labs/genblaze
119
+ - **Examples**: [`veo_video_pipeline.py`](https://github.com/backblaze-labs/genblaze/blob/main/examples/veo_video_pipeline.py) · [`imagen_pipeline.py`](https://github.com/backblaze-labs/genblaze/blob/main/examples/imagen_pipeline.py)
120
+
121
+ ## Related packages
122
+
123
+ - [`genblaze-core`](https://pypi.org/project/genblaze-core/) — the pipeline SDK
124
+ - [`genblaze-s3`](https://pypi.org/project/genblaze-s3/) — durable storage on Backblaze B2 and other S3-compatible backends
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,110 @@
1
+ <!-- last_verified: 2026-04-22 -->
2
+ # genblaze-google
3
+
4
+ **Google provider adapters for [genblaze](https://github.com/backblaze-labs/genblaze) — [Veo](https://deepmind.google/technologies/veo/) text-to-video and [Imagen](https://deepmind.google/technologies/imagen-3/) text-to-image — with SHA-256 provenance manifests on every output.**
5
+
6
+ `genblaze-google` wraps Google's generative media models (Veo 2, Veo 3, Imagen 3) as genblaze providers via the unified `google-genai` SDK. Works with both Gemini API keys and Google Cloud Vertex AI authentication. Compose Veo/Imagen calls into multi-step AI pipelines, persist outputs to [Backblaze B2](https://www.backblaze.com/cloud-storage?utm_source=github&utm_medium=referral&utm_campaign=ai_artifacts&utm_content=genblaze) or any S3-compatible store, and emit a tamper-evident provenance manifest for every run.
7
+
8
+ ## Why genblaze-google
9
+
10
+ - **Veo 3 with synchronized audio** — text-to-video + native audio, wrapped in a provenance manifest.
11
+ - **Imagen 3 high-fidelity images** — photorealistic stills with full parameter tracking.
12
+ - **Two auth modes** — Gemini API (`GEMINI_API_KEY`) for quick start, Vertex AI for enterprise / GCP orgs.
13
+ - **Same SDK, any provider** — swap to Sora, Runway, Luma, Flux without rewriting pipeline logic.
14
+ - **Provenance by default** — SHA-256 hash + canonical manifest on every generation.
15
+ - **Durable storage** — plug `genblaze-s3` in for Backblaze B2 / AWS S3 / Cloudflare R2 / MinIO.
16
+
17
+ ## Providers + models
18
+
19
+ | Provider class | Modality | Models |
20
+ |---|---|---|
21
+ | `VeoProvider` | video | `veo-3.0-generate-001` (with audio), `veo-3.0-fast-generate-001`, `veo-2.0-generate-001` |
22
+ | `ImagenProvider` | image | `imagen-3.0-generate-002`, `imagen-3.0-fast-generate-001` |
23
+
24
+ Each is registered via entry points (`google-veo`, `google-imagen`).
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install genblaze-google
30
+ ```
31
+
32
+ ## Quickstart — Veo 3 text-to-video (with audio)
33
+
34
+ ```bash
35
+ export GEMINI_API_KEY="..." # or use Vertex AI auth
36
+ ```
37
+
38
+ ```python
39
+ from genblaze_core import Modality, Pipeline
40
+ from genblaze_google import VeoProvider
41
+
42
+ run, manifest = (
43
+ Pipeline("veo-demo")
44
+ .step(VeoProvider(), model="veo-3.0-generate-001",
45
+ prompt="A time-lapse of a coral reef coming to life, colorful fish "
46
+ "swimming through vibrant coral, natural ocean lighting",
47
+ modality=Modality.VIDEO,
48
+ aspect_ratio="16:9", duration_seconds="8", resolution="720p",
49
+ enhance_prompt=True)
50
+ .run(timeout=600)
51
+ )
52
+ print(run.steps[0].assets[0].url, manifest.canonical_hash)
53
+ ```
54
+
55
+ Vertex AI auth instead:
56
+
57
+ ```python
58
+ provider = VeoProvider(project="my-gcp-project", location="us-central1")
59
+ ```
60
+
61
+ ## Quickstart — Imagen 3 text-to-image
62
+
63
+ ```python
64
+ from genblaze_google import ImagenProvider
65
+
66
+ run, manifest = (
67
+ Pipeline("imagen-demo")
68
+ .step(ImagenProvider(output_dir="output/images"),
69
+ model="imagen-3.0-generate-002",
70
+ prompt="A photorealistic aerial view of a coral reef teeming with tropical fish",
71
+ modality=Modality.IMAGE, aspect_ratio="16:9")
72
+ .run(timeout=120)
73
+ )
74
+ ```
75
+
76
+ ## Persist to Backblaze B2
77
+
78
+ ```python
79
+ from genblaze_core import KeyStrategy, ObjectStorageSink
80
+ from genblaze_s3 import S3StorageBackend
81
+
82
+ storage = ObjectStorageSink(
83
+ S3StorageBackend.for_backblaze("my-bucket"),
84
+ key_strategy=KeyStrategy.HIERARCHICAL,
85
+ )
86
+ # pass sink=storage to .run(…) to push assets + manifest to B2
87
+ ```
88
+
89
+ [Backblaze B2](https://www.backblaze.com/cloud-storage?utm_source=github&utm_medium=referral&utm_campaign=ai_artifacts&utm_content=genblaze) is the recommended default sink for genblaze — cost-efficient, S3-compatible, with Object Lock for tamper-evident manifests.
90
+
91
+ ## Credentials
92
+
93
+ | Auth mode | Env var / config |
94
+ |---|---|
95
+ | Gemini API (quickest) | `GEMINI_API_KEY` — https://aistudio.google.com/apikey |
96
+ | Vertex AI | `VeoProvider(project=..., location=...)` + `gcloud auth application-default login` |
97
+
98
+ ## Documentation
99
+
100
+ - **Main repo**: https://github.com/backblaze-labs/genblaze
101
+ - **Examples**: [`veo_video_pipeline.py`](https://github.com/backblaze-labs/genblaze/blob/main/examples/veo_video_pipeline.py) · [`imagen_pipeline.py`](https://github.com/backblaze-labs/genblaze/blob/main/examples/imagen_pipeline.py)
102
+
103
+ ## Related packages
104
+
105
+ - [`genblaze-core`](https://pypi.org/project/genblaze-core/) — the pipeline SDK
106
+ - [`genblaze-s3`](https://pypi.org/project/genblaze-s3/) — durable storage on Backblaze B2 and other S3-compatible backends
107
+
108
+ ## License
109
+
110
+ MIT
@@ -0,0 +1,6 @@
1
+ """Google provider adapters for genblaze (Veo video, Imagen image)."""
2
+
3
+ from genblaze_google.imagen import ImagenProvider
4
+ from genblaze_google.provider import VeoProvider
5
+
6
+ __all__ = ["VeoProvider", "ImagenProvider"]
@@ -0,0 +1,19 @@
1
+ """Shared Google API error mapping — used by provider.py, imagen.py."""
2
+
3
+ from genblaze_core.models.enums import ProviderErrorCode
4
+
5
+
6
+ def map_google_error(exc: Exception) -> ProviderErrorCode:
7
+ """Map a Google API exception to a ProviderErrorCode."""
8
+ msg = str(exc).lower()
9
+ if "rate" in msg or "429" in msg or "resource_exhausted" in msg:
10
+ return ProviderErrorCode.RATE_LIMIT
11
+ if "auth" in msg or "401" in msg or "403" in msg or "permission" in msg:
12
+ return ProviderErrorCode.AUTH_FAILURE
13
+ if "invalid" in msg or "400" in msg:
14
+ return ProviderErrorCode.INVALID_INPUT
15
+ if "timeout" in msg or "deadline" in msg:
16
+ return ProviderErrorCode.TIMEOUT
17
+ if "500" in msg or "unavailable" in msg or "internal" in msg:
18
+ return ProviderErrorCode.SERVER_ERROR
19
+ return ProviderErrorCode.UNKNOWN
@@ -0,0 +1,162 @@
1
+ """ImagenProvider — adapter for Google Imagen image generation.
2
+
3
+ Synchronous API: client.models.generate_images() returns images directly.
4
+
5
+ Docs: https://ai.google.dev/gemini-api/docs/image-generation
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import tempfile
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from urllib.parse import quote
15
+
16
+ from genblaze_core.exceptions import ProviderError
17
+ from genblaze_core.models.asset import Asset
18
+ from genblaze_core.models.enums import Modality, ProviderErrorCode
19
+ from genblaze_core.models.step import Step
20
+ from genblaze_core.providers.base import ProviderCapabilities, SyncProvider
21
+ from genblaze_core.runnable.config import RunnableConfig
22
+
23
+ from genblaze_google._errors import map_google_error
24
+
25
+ _VALID_ASPECT_RATIOS = {"1:1", "3:4", "4:3", "9:16", "16:9"}
26
+
27
+ # Per-image pricing by model (USD)
28
+ _IMAGEN_PRICING: dict[str, float] = {
29
+ "imagen-3.0-generate-002": 0.04,
30
+ "imagen-3.0-fast-generate-001": 0.02,
31
+ }
32
+
33
+
34
+ class ImagenProvider(SyncProvider):
35
+ """Provider adapter for Google Imagen image generation.
36
+
37
+ Models: ``imagen-3.0-generate-002`` (latest), ``imagen-3.0-fast-generate-001``.
38
+
39
+ Imagen returns image bytes directly (synchronous, not operation-based).
40
+ Output is saved to files; use ObjectStorageSink for cloud upload.
41
+
42
+ Args:
43
+ api_key: Gemini API key. Falls back to GEMINI_API_KEY env var.
44
+ project: GCP project ID for Vertex AI auth.
45
+ location: GCP region for Vertex AI (default "us-central1").
46
+ output_dir: Directory for output image files (default system temp).
47
+ """
48
+
49
+ name = "google-imagen"
50
+
51
+ def get_capabilities(self) -> ProviderCapabilities:
52
+ """Imagen: image generation from text prompts."""
53
+ return ProviderCapabilities(
54
+ supported_modalities=[Modality.IMAGE],
55
+ supported_inputs=["text"],
56
+ models=["imagen-3.0-generate-002", "imagen-3.0-fast-generate-001"],
57
+ output_formats=["image/png", "image/jpeg"],
58
+ )
59
+
60
+ def __init__(
61
+ self,
62
+ api_key: str | None = None,
63
+ *,
64
+ project: str | None = None,
65
+ location: str = "us-central1",
66
+ output_dir: str | Path | None = None,
67
+ ):
68
+ super().__init__()
69
+ self._api_key = api_key
70
+ self._project = project
71
+ self._location = location
72
+ self._output_dir = Path(output_dir) if output_dir else None
73
+ self._client: Any = None
74
+
75
+ def _get_client(self):
76
+ if self._client is None:
77
+ try:
78
+ from google import genai
79
+ except ImportError as exc:
80
+ raise ProviderError(
81
+ "google-genai package not installed. Run: pip install google-genai"
82
+ ) from exc
83
+ if self._project:
84
+ self._client = genai.Client(
85
+ vertexai=True, project=self._project, location=self._location
86
+ )
87
+ else:
88
+ kwargs: dict = {}
89
+ if self._api_key:
90
+ kwargs["api_key"] = self._api_key
91
+ self._client = genai.Client(**kwargs)
92
+ return self._client
93
+
94
+ def generate(self, step: Step, config: RunnableConfig | None = None) -> Step:
95
+ """Generate image(s) via the Google Imagen API."""
96
+ client = self._get_client()
97
+ try:
98
+ from google.genai import types
99
+
100
+ config_kwargs: dict = {}
101
+
102
+ if "number_of_images" in step.params:
103
+ config_kwargs["number_of_images"] = int(step.params["number_of_images"])
104
+ if "aspect_ratio" in step.params:
105
+ ar = step.params["aspect_ratio"]
106
+ if ar not in _VALID_ASPECT_RATIOS:
107
+ raise ProviderError(
108
+ f"Invalid aspect_ratio={ar!r}. Must be one of {_VALID_ASPECT_RATIOS}",
109
+ error_code=ProviderErrorCode.INVALID_INPUT,
110
+ )
111
+ config_kwargs["aspect_ratio"] = ar
112
+ if "person_generation" in step.params:
113
+ config_kwargs["person_generation"] = step.params["person_generation"]
114
+ if "safety_filter_level" in step.params:
115
+ config_kwargs["safety_filter_level"] = step.params["safety_filter_level"]
116
+ if "output_mime_type" in step.params:
117
+ config_kwargs["output_mime_type"] = step.params["output_mime_type"]
118
+
119
+ gen_config = types.GenerateImagesConfig(**config_kwargs) if config_kwargs else None
120
+
121
+ kwargs: dict = {"model": step.model, "prompt": step.prompt or ""}
122
+ if gen_config is not None:
123
+ kwargs["config"] = gen_config
124
+
125
+ response = client.models.generate_images(**kwargs)
126
+
127
+ # Safety filter can return 0 images without raising an error
128
+ if not response.generated_images:
129
+ raise ProviderError(
130
+ "Imagen returned no images — prompt may have been blocked by safety filters",
131
+ error_code=ProviderErrorCode.INVALID_INPUT,
132
+ )
133
+
134
+ mime_type = step.params.get("output_mime_type", "image/png")
135
+ suffix = ".jpeg" if "jpeg" in mime_type else ".png"
136
+
137
+ for i, img in enumerate(response.generated_images):
138
+ if self._output_dir:
139
+ self._output_dir.mkdir(parents=True, exist_ok=True)
140
+ out_path = self._output_dir / f"{step.step_id}_{i}{suffix}"
141
+ else:
142
+ fd, tmp = tempfile.mkstemp(suffix=suffix)
143
+ os.close(fd)
144
+ out_path = Path(tmp)
145
+
146
+ img.image.save(str(out_path))
147
+ file_url = f"file://{quote(str(out_path.resolve()))}"
148
+ step.assets.append(Asset(url=file_url, media_type=mime_type))
149
+
150
+ # Track cost
151
+ price = _IMAGEN_PRICING.get(step.model)
152
+ if price is not None:
153
+ step.cost_usd = price * len(step.assets)
154
+
155
+ return step
156
+ except ProviderError:
157
+ raise
158
+ except Exception as exc:
159
+ raise ProviderError(
160
+ f"Imagen generation failed: {exc}",
161
+ error_code=map_google_error(exc),
162
+ ) from exc
@@ -0,0 +1,276 @@
1
+ """VeoProvider — adapter for Google Veo video generation.
2
+
3
+ Uses the google-genai SDK with the async operation-based workflow:
4
+ client.models.generate_videos() → poll operation → download video
5
+
6
+ Docs: https://ai.google.dev/gemini-api/docs/video
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from genblaze_core.exceptions import ProviderError
14
+ from genblaze_core.models.asset import Asset, AudioMetadata, Track, VideoMetadata
15
+ from genblaze_core.models.enums import Modality, ProviderErrorCode
16
+ from genblaze_core.models.step import Step
17
+ from genblaze_core.providers.base import BaseProvider, ProviderCapabilities, validate_asset_url
18
+ from genblaze_core.runnable.config import RunnableConfig
19
+
20
+ from genblaze_google._errors import map_google_error
21
+
22
+ # Valid Veo parameter values
23
+ _VALID_ASPECT_RATIOS = {"16:9", "9:16"}
24
+ _VALID_RESOLUTIONS = {"720p", "1080p", "4k"}
25
+ _VALID_DURATIONS = {"4", "6", "8"}
26
+
27
+ # Per-second pricing by model (USD)
28
+ _VEO_PRICING: dict[str, float] = {
29
+ "veo-2.0-generate-001": 0.35,
30
+ "veo-3.0-generate-001": 0.50,
31
+ "veo-3.0-fast-generate-001": 0.25,
32
+ }
33
+
34
+
35
+ class VeoProvider(BaseProvider):
36
+ """Provider adapter for Google Veo video generation.
37
+
38
+ Models: ``veo-2.0-generate-001`` (stable), ``veo-3.0-generate-001`` (GA, audio),
39
+ ``veo-3.0-fast-generate-001`` (GA, fast).
40
+
41
+ Supports both Gemini API (api_key) and Vertex AI (project/location) auth.
42
+
43
+ Args:
44
+ api_key: Gemini API key. Falls back to GEMINI_API_KEY env var.
45
+ project: GCP project ID for Vertex AI auth (mutually exclusive with api_key).
46
+ location: GCP region for Vertex AI (default "us-central1").
47
+ poll_interval: Seconds between operation polls (default 10).
48
+ """
49
+
50
+ name = "google-veo"
51
+
52
+ def get_capabilities(self) -> ProviderCapabilities:
53
+ """Veo: video generation from text prompts with configurable resolution and duration."""
54
+ return ProviderCapabilities(
55
+ supported_modalities=[Modality.VIDEO],
56
+ supported_inputs=["text"],
57
+ max_duration=8.0,
58
+ resolutions=["720p", "1080p", "4k"],
59
+ models=[
60
+ "veo-2.0-generate-001",
61
+ "veo-3.0-generate-001",
62
+ "veo-3.0-fast-generate-001",
63
+ ],
64
+ output_formats=["video/mp4"],
65
+ )
66
+
67
+ def __init__(
68
+ self,
69
+ api_key: str | None = None,
70
+ *,
71
+ project: str | None = None,
72
+ location: str = "us-central1",
73
+ poll_interval: float = 10.0,
74
+ ):
75
+ super().__init__()
76
+ self.poll_interval = poll_interval
77
+ self._api_key = api_key
78
+ self._project = project
79
+ self._location = location
80
+ self._client: Any = None
81
+
82
+ def normalize_params(self, params: dict, modality: Any = None) -> dict:
83
+ """Map standard params to Veo-native names."""
84
+ p = dict(params)
85
+ # duration → duration_seconds (Veo's native key)
86
+ if "duration" in p and "duration_seconds" not in p:
87
+ p["duration_seconds"] = p.pop("duration")
88
+ return p
89
+
90
+ def _get_client(self):
91
+ if self._client is None:
92
+ try:
93
+ from google import genai
94
+ except ImportError as exc:
95
+ raise ProviderError(
96
+ "google-genai package not installed. Run: pip install google-genai"
97
+ ) from exc
98
+
99
+ if self._project:
100
+ # Vertex AI auth
101
+ self._client = genai.Client(
102
+ vertexai=True,
103
+ project=self._project,
104
+ location=self._location,
105
+ )
106
+ else:
107
+ # Gemini API key auth
108
+ kwargs: dict = {}
109
+ if self._api_key:
110
+ kwargs["api_key"] = self._api_key
111
+ self._client = genai.Client(**kwargs)
112
+ return self._client
113
+
114
+ def _build_config(self, step: Step) -> Any:
115
+ """Build a GenerateVideosConfig from step.params."""
116
+ from google.genai import types
117
+
118
+ config_kwargs: dict = {}
119
+
120
+ if "aspect_ratio" in step.params:
121
+ ar = step.params["aspect_ratio"]
122
+ if ar not in _VALID_ASPECT_RATIOS:
123
+ raise ProviderError(
124
+ f"Invalid aspect_ratio={ar!r}. Must be one of {_VALID_ASPECT_RATIOS}",
125
+ error_code=ProviderErrorCode.INVALID_INPUT,
126
+ )
127
+ config_kwargs["aspect_ratio"] = ar
128
+
129
+ if "resolution" in step.params:
130
+ res = step.params["resolution"]
131
+ if res not in _VALID_RESOLUTIONS:
132
+ raise ProviderError(
133
+ f"Invalid resolution={res!r}. Must be one of {_VALID_RESOLUTIONS}",
134
+ error_code=ProviderErrorCode.INVALID_INPUT,
135
+ )
136
+ config_kwargs["resolution"] = res
137
+
138
+ # Duration (normalize_params already mapped duration → duration_seconds)
139
+ raw_dur = step.params.get("duration_seconds")
140
+ if raw_dur is not None:
141
+ dur = str(raw_dur)
142
+ if dur not in _VALID_DURATIONS:
143
+ raise ProviderError(
144
+ f"Invalid duration_seconds={dur!r}. Must be one of {_VALID_DURATIONS}",
145
+ error_code=ProviderErrorCode.INVALID_INPUT,
146
+ )
147
+ config_kwargs["duration_seconds"] = dur
148
+
149
+ if "person_generation" in step.params:
150
+ config_kwargs["person_generation"] = step.params["person_generation"]
151
+
152
+ if "number_of_videos" in step.params:
153
+ config_kwargs["number_of_videos"] = int(step.params["number_of_videos"])
154
+
155
+ if "enhance_prompt" in step.params:
156
+ config_kwargs["enhance_prompt"] = bool(step.params["enhance_prompt"])
157
+
158
+ if step.seed is not None:
159
+ config_kwargs["seed"] = step.seed
160
+
161
+ return types.GenerateVideosConfig(**config_kwargs) if config_kwargs else None
162
+
163
+ def submit(self, step: Step, config: RunnableConfig | None = None) -> Any:
164
+ """Start a video generation operation."""
165
+ client = self._get_client()
166
+ try:
167
+ gen_config = self._build_config(step)
168
+ kwargs: dict = {
169
+ "model": step.model,
170
+ "prompt": step.prompt or "",
171
+ }
172
+ if gen_config is not None:
173
+ kwargs["config"] = gen_config
174
+
175
+ operation = client.models.generate_videos(**kwargs)
176
+ # Return the provider-native operation name for resume() compatibility
177
+ return operation.name
178
+ except ProviderError:
179
+ raise
180
+ except Exception as exc:
181
+ raise ProviderError(
182
+ f"Veo submit failed: {exc}",
183
+ error_code=map_google_error(exc),
184
+ ) from exc
185
+
186
+ def poll(self, prediction_id: Any, config: RunnableConfig | None = None) -> bool:
187
+ """Check if the video generation operation is done."""
188
+ client = self._get_client()
189
+ try:
190
+ operation = client.operations.get(prediction_id)
191
+ if operation.done:
192
+ self._cache_poll_result(prediction_id, operation)
193
+ return True
194
+ return False
195
+ except ProviderError:
196
+ raise
197
+ except Exception as exc:
198
+ raise ProviderError(
199
+ f"Veo poll failed: {exc}",
200
+ error_code=map_google_error(exc),
201
+ ) from exc
202
+
203
+ def fetch_output(self, prediction_id: Any, step: Step) -> Step:
204
+ """Download generated video(s) and attach asset URLs."""
205
+ client = self._get_client()
206
+ try:
207
+ # Use cached poll result if available, otherwise fetch fresh
208
+ operation = self._get_cached_poll_result(prediction_id)
209
+ if operation is None:
210
+ operation = client.operations.get(prediction_id)
211
+
212
+ # Store provider metadata
213
+ step.provider_payload = {
214
+ "google": {
215
+ "operation_name": getattr(operation, "name", None),
216
+ "model": step.model,
217
+ }
218
+ }
219
+
220
+ # Check for errors in the operation result
221
+ if hasattr(operation, "error") and operation.error:
222
+ raise ProviderError(
223
+ str(operation.error),
224
+ error_code=ProviderErrorCode.UNKNOWN,
225
+ )
226
+
227
+ response = operation.response
228
+ if response is None or not hasattr(response, "generated_videos"):
229
+ raise ProviderError("No video generated in response")
230
+
231
+ # Veo 3.0 models generate audio alongside video
232
+ is_veo3 = step.model.startswith("veo-3")
233
+
234
+ for gv in response.generated_videos:
235
+ video = gv.video
236
+ # Download to get the file URI
237
+ client.files.download(file=video)
238
+ # Use the video's URI as the asset URL
239
+ video_uri = getattr(video, "uri", None)
240
+ if video_uri:
241
+ validate_asset_url(video_uri)
242
+ vm_kwargs: dict[str, Any] = {"has_audio": is_veo3}
243
+ if "resolution" in step.params:
244
+ vm_kwargs["resolution"] = step.params["resolution"]
245
+ asset = Asset(url=video_uri, media_type="video/mp4")
246
+ asset.video = VideoMetadata(**vm_kwargs)
247
+ # Multi-track metadata for Veo 3 (video + generated audio)
248
+ if is_veo3:
249
+ asset.tracks = [
250
+ Track(kind="video", codec="h264"),
251
+ Track(kind="audio", codec="aac", label="generated-audio"),
252
+ ]
253
+ asset.audio = AudioMetadata(codec="aac")
254
+ step.assets.append(asset)
255
+ else:
256
+ # Fallback: save locally and use file path
257
+ raise ProviderError(
258
+ "Veo response missing video URI — "
259
+ "use client.files.download() to save locally"
260
+ )
261
+
262
+ # Track cost — accept both native and standard duration key
263
+ price_per_sec = _VEO_PRICING.get(step.model)
264
+ if price_per_sec is not None:
265
+ raw_dur = step.params.get("duration_seconds") or step.params.get("duration")
266
+ duration = int(raw_dur) if raw_dur is not None else 4
267
+ step.cost_usd = price_per_sec * duration * len(step.assets)
268
+
269
+ return step
270
+ except ProviderError:
271
+ raise
272
+ except Exception as exc:
273
+ raise ProviderError(
274
+ f"Veo fetch_output failed: {exc}",
275
+ error_code=map_google_error(exc),
276
+ ) from exc
File without changes
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "genblaze-google"
7
+ version = "0.1.0"
8
+ description = "Google provider adapters for genblaze (Veo video, Imagen image)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Typing :: Typed",
16
+ ]
17
+ dependencies = [
18
+ "genblaze-core>=0.1.0,<0.2",
19
+ "google-genai>=1.0",
20
+ ]
21
+
22
+ [project.urls]
23
+ Documentation = "https://github.com/backblaze-labs/genblaze"
24
+ Repository = "https://github.com/backblaze-labs/genblaze"
25
+ Issues = "https://github.com/backblaze-labs/genblaze/issues"
26
+
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "pytest>=7.0",
30
+ ]
31
+
32
+ [project.entry-points."genblaze.providers"]
33
+ google-veo = "genblaze_google:VeoProvider"
34
+ google-imagen = "genblaze_google:ImagenProvider"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["genblaze_google"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
File without changes
@@ -0,0 +1,175 @@
1
+ """Tests for ImagenProvider (mocked — no real API calls)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tempfile
6
+ from unittest.mock import MagicMock, patch
7
+
8
+ import pytest
9
+ from genblaze_core.exceptions import ProviderError
10
+ from genblaze_core.models.enums import StepStatus
11
+ from genblaze_core.models.step import Step
12
+ from genblaze_core.testing import ProviderComplianceTests
13
+
14
+
15
+ @pytest.fixture
16
+ def mock_imagen(tmp_path):
17
+ """Patch google.genai with a mock client."""
18
+ mock_image = MagicMock()
19
+ mock_image.save = MagicMock()
20
+
21
+ mock_response = MagicMock()
22
+ mock_response.generated_images = [MagicMock(image=mock_image)]
23
+
24
+ mock_client = MagicMock()
25
+ mock_client.models.generate_images.return_value = mock_response
26
+
27
+ # Mock the google.genai.types module
28
+ mock_types = MagicMock()
29
+ mock_genai = MagicMock()
30
+ mock_genai.types = mock_types
31
+ mock_google = MagicMock()
32
+ mock_google.genai = mock_genai
33
+
34
+ with patch.dict(
35
+ "sys.modules",
36
+ {"google": mock_google, "google.genai": mock_genai, "google.genai.types": mock_types},
37
+ ):
38
+ from genblaze_google.imagen import ImagenProvider
39
+
40
+ provider = ImagenProvider(api_key="test-key", output_dir=str(tmp_path))
41
+ provider._client = mock_client
42
+ yield provider, mock_client
43
+
44
+
45
+ def test_generate_returns_image_asset(mock_imagen):
46
+ provider, _ = mock_imagen
47
+ step = Step(
48
+ provider="google-imagen",
49
+ model="imagen-3.0-generate-002",
50
+ prompt="a mountain landscape",
51
+ )
52
+ result = provider.generate(step)
53
+ assert len(result.assets) == 1
54
+ assert result.assets[0].media_type == "image/png"
55
+ assert result.assets[0].url.startswith("file://")
56
+
57
+
58
+ def test_invoke_full_lifecycle(mock_imagen):
59
+ provider, _ = mock_imagen
60
+ step = Step(
61
+ provider="google-imagen",
62
+ model="imagen-3.0-generate-002",
63
+ prompt="a sunset",
64
+ )
65
+ result = provider.invoke(step)
66
+ assert result.status == StepStatus.SUCCEEDED
67
+ assert len(result.assets) == 1
68
+
69
+
70
+ def test_invalid_aspect_ratio_raises(mock_imagen):
71
+ provider, _ = mock_imagen
72
+ step = Step(
73
+ provider="google-imagen",
74
+ model="imagen-3.0-generate-002",
75
+ prompt="test",
76
+ params={"aspect_ratio": "5:3"},
77
+ )
78
+ with pytest.raises(ProviderError, match="Invalid aspect_ratio"):
79
+ provider.generate(step)
80
+
81
+
82
+ def test_multiple_images(mock_imagen):
83
+ provider, client = mock_imagen
84
+ img1, img2 = MagicMock(), MagicMock()
85
+ img1.image = MagicMock()
86
+ img2.image = MagicMock()
87
+ client.models.generate_images.return_value = MagicMock(generated_images=[img1, img2])
88
+ step = Step(
89
+ provider="google-imagen",
90
+ model="imagen-3.0-generate-002",
91
+ prompt="cats",
92
+ params={"number_of_images": 2},
93
+ )
94
+ result = provider.generate(step)
95
+ assert len(result.assets) == 2
96
+
97
+
98
+ def test_params_passed_to_api(mock_imagen):
99
+ provider, client = mock_imagen
100
+ step = Step(
101
+ provider="google-imagen",
102
+ model="imagen-3.0-generate-002",
103
+ prompt="test",
104
+ params={"aspect_ratio": "16:9", "number_of_images": 2},
105
+ )
106
+ provider.generate(step)
107
+ client.models.generate_images.assert_called_once()
108
+
109
+
110
+ def test_empty_images_raises_on_safety_filter(mock_imagen):
111
+ """Imagen safety filter returns 0 images — must raise, not return empty assets."""
112
+ provider, client = mock_imagen
113
+ client.models.generate_images.return_value = MagicMock(generated_images=[])
114
+ step = Step(
115
+ provider="google-imagen",
116
+ model="imagen-3.0-generate-002",
117
+ prompt="blocked content",
118
+ )
119
+ with pytest.raises(ProviderError, match="no images"):
120
+ provider.generate(step)
121
+
122
+
123
+ def test_api_error_raises_provider_error(mock_imagen):
124
+ provider, client = mock_imagen
125
+ client.models.generate_images.side_effect = RuntimeError("403 permission denied")
126
+ step = Step(
127
+ provider="google-imagen",
128
+ model="imagen-3.0-generate-002",
129
+ prompt="test",
130
+ )
131
+ with pytest.raises(ProviderError, match="Imagen generation failed"):
132
+ provider.generate(step)
133
+
134
+
135
+ # --- Compliance harness ---
136
+
137
+
138
+ class TestImagenCompliance(ProviderComplianceTests):
139
+ """Verify ImagenProvider satisfies the genblaze provider contract."""
140
+
141
+ @pytest.fixture(autouse=True)
142
+ def _patch_sdk(self):
143
+ mock_types = MagicMock()
144
+ mock_genai = MagicMock()
145
+ mock_google_mod = MagicMock()
146
+ mock_google_mod.genai = mock_genai
147
+ with patch.dict(
148
+ "sys.modules",
149
+ {
150
+ "google": mock_google_mod,
151
+ "google.genai": mock_genai,
152
+ "google.genai.types": mock_types,
153
+ },
154
+ ):
155
+ yield
156
+
157
+ def make_provider(self):
158
+ from genblaze_google.imagen import ImagenProvider
159
+
160
+ mock_image = MagicMock()
161
+ mock_image.save = MagicMock()
162
+ mock_response = MagicMock()
163
+ mock_response.generated_images = [MagicMock(image=mock_image)]
164
+ mock_client = MagicMock()
165
+ mock_client.models.generate_images.return_value = mock_response
166
+ provider = ImagenProvider(api_key="test-key", output_dir=tempfile.mkdtemp())
167
+ provider._client = mock_client
168
+ return provider
169
+
170
+ def make_step(self):
171
+ return Step(
172
+ provider="google-imagen",
173
+ model="imagen-3.0-generate-002",
174
+ prompt="test prompt",
175
+ )
@@ -0,0 +1,324 @@
1
+ """Tests for VeoProvider (mocked — no real API calls)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import SimpleNamespace
6
+ from unittest.mock import MagicMock, patch
7
+
8
+ import pytest
9
+ from genblaze_core.exceptions import ProviderError
10
+ from genblaze_core.models.enums import StepStatus
11
+ from genblaze_core.models.step import Step
12
+ from genblaze_core.testing import ProviderComplianceTests
13
+
14
+
15
+ def _make_completed_operation():
16
+ """Create a mock completed operation with a generated video."""
17
+ video = SimpleNamespace(uri="https://storage.googleapis.com/video/out.mp4")
18
+ gv = SimpleNamespace(video=video)
19
+ response = SimpleNamespace(generated_videos=[gv])
20
+ return SimpleNamespace(done=True, name="op-123", error=None, response=response)
21
+
22
+
23
+ def _make_pending_operation():
24
+ return SimpleNamespace(done=False, name="op-123", error=None, response=None)
25
+
26
+
27
+ @pytest.fixture
28
+ def mock_google():
29
+ """Patch google.genai with a mock client."""
30
+ mock_types = MagicMock()
31
+ mock_types.GenerateVideosConfig = MagicMock
32
+
33
+ mock_genai = MagicMock()
34
+ mock_google_mod = MagicMock()
35
+ mock_google_mod.genai = mock_genai
36
+
37
+ mock_client = MagicMock()
38
+ mock_client.models.generate_videos.return_value = _make_pending_operation()
39
+ mock_client.operations.get.return_value = _make_completed_operation()
40
+ mock_client.files.download.return_value = None
41
+
42
+ with patch.dict(
43
+ "sys.modules",
44
+ {
45
+ "google": mock_google_mod,
46
+ "google.genai": mock_genai,
47
+ "google.genai.types": mock_types,
48
+ },
49
+ ):
50
+ from genblaze_google import VeoProvider
51
+
52
+ provider = VeoProvider(api_key="test-key")
53
+ provider._client = mock_client
54
+ yield provider, mock_client
55
+
56
+
57
+ def test_submit_returns_operation_name(mock_google):
58
+ provider, client = mock_google
59
+ step = Step(provider="google-veo", model="veo-2.0-generate-001", prompt="a sunset")
60
+ pred_id = provider.submit(step)
61
+ # Returns the provider-native operation name, not step_id
62
+ assert pred_id == "op-123"
63
+ client.models.generate_videos.assert_called_once()
64
+
65
+
66
+ def test_poll_returns_false_when_pending(mock_google):
67
+ provider, client = mock_google
68
+ step = Step(provider="google-veo", model="veo-2.0-generate-001", prompt="a sunset")
69
+ pred_id = provider.submit(step)
70
+ client.operations.get.return_value = _make_pending_operation()
71
+ assert provider.poll(pred_id) is False
72
+
73
+
74
+ def test_poll_returns_true_when_done(mock_google):
75
+ provider, client = mock_google
76
+ step = Step(provider="google-veo", model="veo-2.0-generate-001", prompt="a sunset")
77
+ pred_id = provider.submit(step)
78
+ client.operations.get.return_value = _make_completed_operation()
79
+ assert provider.poll(pred_id) is True
80
+
81
+
82
+ def test_fetch_output_attaches_asset(mock_google):
83
+ provider, client = mock_google
84
+ step = Step(provider="google-veo", model="veo-2.0-generate-001", prompt="a sunset")
85
+ pred_id = provider.submit(step)
86
+ # Poll to cache the completed operation
87
+ client.operations.get.return_value = _make_completed_operation()
88
+ provider.poll(pred_id)
89
+
90
+ result = provider.fetch_output(pred_id, step)
91
+ assert len(result.assets) == 1
92
+ assert result.assets[0].media_type == "video/mp4"
93
+ assert "storage.googleapis.com" in result.assets[0].url
94
+
95
+
96
+ def test_fetch_output_error_raises(mock_google):
97
+ provider, client = mock_google
98
+ step = Step(provider="google-veo", model="veo-2.0-generate-001", prompt="bad")
99
+ pred_id = provider.submit(step)
100
+ error_op = SimpleNamespace(
101
+ done=True, name="op-err", error="Safety filter triggered", response=None
102
+ )
103
+ client.operations.get.return_value = error_op
104
+ provider.poll(pred_id)
105
+
106
+ with pytest.raises(ProviderError, match="Safety filter"):
107
+ provider.fetch_output(pred_id, step)
108
+
109
+
110
+ def test_invoke_full_lifecycle(mock_google):
111
+ """Full invoke() succeeds with mocked client."""
112
+ provider, client = mock_google
113
+ client.operations.get.return_value = _make_completed_operation()
114
+
115
+ step = Step(provider="google-veo", model="veo-2.0-generate-001", prompt="a sunset")
116
+ result = provider.invoke(step)
117
+ assert result.status == StepStatus.SUCCEEDED
118
+ assert len(result.assets) == 1
119
+
120
+
121
+ def test_invalid_aspect_ratio_raises(mock_google):
122
+ provider, _ = mock_google
123
+ step = Step(
124
+ provider="google-veo",
125
+ model="veo-2.0-generate-001",
126
+ prompt="test",
127
+ params={"aspect_ratio": "4:3"},
128
+ )
129
+ with pytest.raises(ProviderError, match="Invalid aspect_ratio"):
130
+ provider.submit(step)
131
+
132
+
133
+ def test_invalid_resolution_raises(mock_google):
134
+ provider, _ = mock_google
135
+ step = Step(
136
+ provider="google-veo",
137
+ model="veo-2.0-generate-001",
138
+ prompt="test",
139
+ params={"resolution": "360p"},
140
+ )
141
+ with pytest.raises(ProviderError, match="Invalid resolution"):
142
+ provider.submit(step)
143
+
144
+
145
+ def test_resume_works_with_operation_name(mock_google):
146
+ """resume() works with just the operation name (no in-memory state needed)."""
147
+ provider, client = mock_google
148
+ client.operations.get.return_value = _make_completed_operation()
149
+ step = Step(provider="google-veo", model="veo-2.0-generate-001", prompt="a sunset")
150
+ # resume with just the operation name — no prior submit() needed
151
+ result = provider.resume("op-123", step)
152
+ assert result.status == StepStatus.SUCCEEDED
153
+ assert len(result.assets) == 1
154
+
155
+
156
+ def test_duration_alias(mock_google):
157
+ """Standard 'duration' param is aliased to 'duration_seconds' for Veo."""
158
+ provider, client = mock_google
159
+ step = Step(
160
+ provider="google-veo",
161
+ model="veo-2.0-generate-001",
162
+ prompt="test",
163
+ params={"duration": "6"},
164
+ )
165
+ provider.submit(step)
166
+ client.models.generate_videos.assert_called_once()
167
+
168
+
169
+ def test_cost_tracked(mock_google):
170
+ """Cost is set based on model and duration."""
171
+ provider, client = mock_google
172
+ step = Step(
173
+ provider="google-veo",
174
+ model="veo-2.0-generate-001",
175
+ prompt="a sunset",
176
+ params={"duration_seconds": "6"},
177
+ )
178
+ pred_id = provider.submit(step)
179
+ client.operations.get.return_value = _make_completed_operation()
180
+ provider.poll(pred_id)
181
+ result = provider.fetch_output(pred_id, step)
182
+ assert result.cost_usd == pytest.approx(0.35 * 6)
183
+
184
+
185
+ def test_cost_tracked_duration_alias(mock_google):
186
+ """Cost uses the standard 'duration' alias."""
187
+ provider, client = mock_google
188
+ step = Step(
189
+ provider="google-veo",
190
+ model="veo-3.0-generate-001",
191
+ prompt="a sunset",
192
+ params={"duration": "8"},
193
+ )
194
+ pred_id = provider.submit(step)
195
+ client.operations.get.return_value = _make_completed_operation()
196
+ provider.poll(pred_id)
197
+ result = provider.fetch_output(pred_id, step)
198
+ assert result.cost_usd == pytest.approx(0.50 * 8)
199
+
200
+
201
+ def test_cost_default_duration(mock_google):
202
+ """Cost uses default 4s when no duration specified."""
203
+ provider, client = mock_google
204
+ step = Step(
205
+ provider="google-veo",
206
+ model="veo-2.0-generate-001",
207
+ prompt="a sunset",
208
+ )
209
+ pred_id = provider.submit(step)
210
+ client.operations.get.return_value = _make_completed_operation()
211
+ provider.poll(pred_id)
212
+ result = provider.fetch_output(pred_id, step)
213
+ assert result.cost_usd == pytest.approx(0.35 * 4)
214
+
215
+
216
+ def test_cost_none_unknown_model(mock_google):
217
+ """Cost stays None for unknown model."""
218
+ provider, client = mock_google
219
+ step = Step(
220
+ provider="google-veo",
221
+ model="unknown-veo-model",
222
+ prompt="a sunset",
223
+ )
224
+ pred_id = provider.submit(step)
225
+ client.operations.get.return_value = _make_completed_operation()
226
+ provider.poll(pred_id)
227
+ result = provider.fetch_output(pred_id, step)
228
+ assert result.cost_usd is None
229
+
230
+
231
+ def test_veo3_populates_tracks(mock_google):
232
+ """Veo 3 models populate multi-track metadata on assets."""
233
+ provider, client = mock_google
234
+ step = Step(
235
+ provider="google-veo",
236
+ model="veo-3.0-generate-001",
237
+ prompt="a sunset with music",
238
+ )
239
+ pred_id = provider.submit(step)
240
+ client.operations.get.return_value = _make_completed_operation()
241
+ provider.poll(pred_id)
242
+ result = provider.fetch_output(pred_id, step)
243
+ assert len(result.assets) == 1
244
+ asset = result.assets[0]
245
+ assert asset.tracks is not None
246
+ assert len(asset.tracks) == 2
247
+ assert asset.tracks[0].kind == "video"
248
+ assert asset.tracks[0].codec == "h264"
249
+ assert asset.tracks[1].kind == "audio"
250
+ assert asset.tracks[1].codec == "aac"
251
+ assert asset.tracks[1].label == "generated-audio"
252
+
253
+
254
+ def test_veo3_fast_populates_tracks(mock_google):
255
+ """Veo 3 fast model also populates tracks."""
256
+ provider, client = mock_google
257
+ step = Step(
258
+ provider="google-veo",
259
+ model="veo-3.0-fast-generate-001",
260
+ prompt="quick clip",
261
+ )
262
+ pred_id = provider.submit(step)
263
+ client.operations.get.return_value = _make_completed_operation()
264
+ provider.poll(pred_id)
265
+ result = provider.fetch_output(pred_id, step)
266
+ assert result.assets[0].tracks is not None
267
+ assert len(result.assets[0].tracks) == 2
268
+
269
+
270
+ def test_veo2_no_tracks(mock_google):
271
+ """Veo 2 models do not populate tracks."""
272
+ provider, client = mock_google
273
+ step = Step(
274
+ provider="google-veo",
275
+ model="veo-2.0-generate-001",
276
+ prompt="a sunset",
277
+ )
278
+ pred_id = provider.submit(step)
279
+ client.operations.get.return_value = _make_completed_operation()
280
+ provider.poll(pred_id)
281
+ result = provider.fetch_output(pred_id, step)
282
+ assert result.assets[0].tracks is None
283
+
284
+
285
+ # --- Compliance harness ---
286
+
287
+
288
+ class TestVeoCompliance(ProviderComplianceTests):
289
+ """Verify VeoProvider satisfies the genblaze provider contract."""
290
+
291
+ @pytest.fixture(autouse=True)
292
+ def _patch_sdk(self):
293
+ mock_types = MagicMock()
294
+ mock_types.GenerateVideosConfig = MagicMock
295
+ mock_genai = MagicMock()
296
+ mock_google_mod = MagicMock()
297
+ mock_google_mod.genai = mock_genai
298
+ with patch.dict(
299
+ "sys.modules",
300
+ {
301
+ "google": mock_google_mod,
302
+ "google.genai": mock_genai,
303
+ "google.genai.types": mock_types,
304
+ },
305
+ ):
306
+ yield
307
+
308
+ def make_provider(self):
309
+ from genblaze_google import VeoProvider
310
+
311
+ mock_client = MagicMock()
312
+ mock_client.models.generate_videos.return_value = _make_pending_operation()
313
+ mock_client.operations.get.return_value = _make_completed_operation()
314
+ mock_client.files.download.return_value = None
315
+ provider = VeoProvider(api_key="test-key")
316
+ provider._client = mock_client
317
+ return provider
318
+
319
+ def make_step(self):
320
+ return Step(
321
+ provider="google-veo",
322
+ model="veo-2.0-generate-001",
323
+ prompt="test prompt",
324
+ )