runapi-gemini-omni 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,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,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: runapi-gemini-omni
3
+ Version: 0.1.0
4
+ Summary: Gemini Omni voice, character, and text-to-video client for RunAPI
5
+ Project-URL: Homepage, https://runapi.ai/models/gemini-omni
6
+ Project-URL: Documentation, https://runapi.ai/docs#sdk-gemini-omni
7
+ Author-email: RunAPI <contact@runapi.ai>
8
+ License-Expression: Apache-2.0
9
+ Keywords: ai,gemini,gemini-omni,runapi,sdk,text-to-video
10
+ Requires-Python: >=3.9
11
+ Requires-Dist: runapi-core
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Gemini Omni Python SDK for RunAPI
15
+
16
+ The Gemini Omni Python SDK is the language-specific package for Gemini Omni on
17
+ RunAPI. Use it to create reusable voices and characters and to generate video
18
+ from text, with JSON request bodies, task status lookup, and consistent RunAPI
19
+ errors in Python.
20
+
21
+ For model details, use https://runapi.ai/models/gemini-omni; for API reference,
22
+ use https://runapi.ai/docs#gemini-omni; for SDK docs, use
23
+ https://runapi.ai/docs#sdk-gemini-omni.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install runapi-gemini-omni
29
+ ```
30
+
31
+ ## Quick start
32
+
33
+ ```python
34
+ from runapi.gemini_omni import GeminiOmniClient
35
+
36
+ client = GeminiOmniClient() # reads RUNAPI_API_KEY, or pass api_key="sk-..."
37
+
38
+ # Reusable voice (returns immediately)
39
+ voice = client.create_audio.run(audio_id="kore", name="Narrator")
40
+
41
+ # Reusable character (returns immediately)
42
+ character = client.create_character.run(
43
+ descriptions="A friendly robot guide",
44
+ reference_image_url="https://example.com/robot.png",
45
+ )
46
+
47
+ # Text-to-video (create + poll until complete)
48
+ result = client.text_to_video.run(
49
+ prompt="A fox trotting across fresh snow at dawn",
50
+ duration_seconds=8,
51
+ aspect_ratio="16:9",
52
+ )
53
+ print(result.videos[0].url)
54
+ ```
55
+
56
+ `create_audio` and `create_character` are synchronous: `run` submits and returns
57
+ the result directly. `text_to_video` is asynchronous: use `create` to submit and
58
+ return quickly, `get` to fetch the latest task state, and `run` to create and
59
+ poll until completion.
60
+
61
+ RunAPI-generated file URLs are temporary. Download and store generated files in
62
+ your own durable storage within 7 days; do not treat returned URLs as long-term
63
+ assets.
64
+
65
+ ## Language notes
66
+
67
+ Pass parameters as keyword arguments and catch the `runapi.gemini_omni` error
68
+ classes. The available resources are `create_audio`, `create_character`, and
69
+ `text_to_video`. Keep `RUNAPI_API_KEY` in the environment or your secret
70
+ manager; never commit API keys or callback secrets.
71
+
72
+ ## Links
73
+
74
+ - Model page: https://runapi.ai/models/gemini-omni
75
+ - SDK docs: https://runapi.ai/docs#sdk-gemini-omni
76
+ - Product docs: https://runapi.ai/docs#gemini-omni
77
+ - Pricing and rate limits: https://runapi.ai/models/gemini-omni
78
+ - Full catalog: https://runapi.ai/models
79
+
80
+ ## License
81
+
82
+ Licensed under the Apache License, Version 2.0.
@@ -0,0 +1,69 @@
1
+ # Gemini Omni Python SDK for RunAPI
2
+
3
+ The Gemini Omni Python SDK is the language-specific package for Gemini Omni on
4
+ RunAPI. Use it to create reusable voices and characters and to generate video
5
+ from text, with JSON request bodies, task status lookup, and consistent RunAPI
6
+ errors in Python.
7
+
8
+ For model details, use https://runapi.ai/models/gemini-omni; for API reference,
9
+ use https://runapi.ai/docs#gemini-omni; for SDK docs, use
10
+ https://runapi.ai/docs#sdk-gemini-omni.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install runapi-gemini-omni
16
+ ```
17
+
18
+ ## Quick start
19
+
20
+ ```python
21
+ from runapi.gemini_omni import GeminiOmniClient
22
+
23
+ client = GeminiOmniClient() # reads RUNAPI_API_KEY, or pass api_key="sk-..."
24
+
25
+ # Reusable voice (returns immediately)
26
+ voice = client.create_audio.run(audio_id="kore", name="Narrator")
27
+
28
+ # Reusable character (returns immediately)
29
+ character = client.create_character.run(
30
+ descriptions="A friendly robot guide",
31
+ reference_image_url="https://example.com/robot.png",
32
+ )
33
+
34
+ # Text-to-video (create + poll until complete)
35
+ result = client.text_to_video.run(
36
+ prompt="A fox trotting across fresh snow at dawn",
37
+ duration_seconds=8,
38
+ aspect_ratio="16:9",
39
+ )
40
+ print(result.videos[0].url)
41
+ ```
42
+
43
+ `create_audio` and `create_character` are synchronous: `run` submits and returns
44
+ the result directly. `text_to_video` is asynchronous: use `create` to submit and
45
+ return quickly, `get` to fetch the latest task state, and `run` to create and
46
+ poll until completion.
47
+
48
+ RunAPI-generated file URLs are temporary. Download and store generated files in
49
+ your own durable storage within 7 days; do not treat returned URLs as long-term
50
+ assets.
51
+
52
+ ## Language notes
53
+
54
+ Pass parameters as keyword arguments and catch the `runapi.gemini_omni` error
55
+ classes. The available resources are `create_audio`, `create_character`, and
56
+ `text_to_video`. Keep `RUNAPI_API_KEY` in the environment or your secret
57
+ manager; never commit API keys or callback secrets.
58
+
59
+ ## Links
60
+
61
+ - Model page: https://runapi.ai/models/gemini-omni
62
+ - SDK docs: https://runapi.ai/docs#sdk-gemini-omni
63
+ - Product docs: https://runapi.ai/docs#gemini-omni
64
+ - Pricing and rate limits: https://runapi.ai/models/gemini-omni
65
+ - Full catalog: https://runapi.ai/models
66
+
67
+ ## License
68
+
69
+ 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-gemini-omni"
7
+ version = "0.1.0"
8
+ description = "Gemini Omni voice, character, and text-to-video 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", "gemini", "gemini-omni", "text-to-video", "ai", "sdk"]
14
+ dependencies = ["runapi-core"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://runapi.ai/models/gemini-omni"
18
+ Documentation = "https://runapi.ai/docs#sdk-gemini-omni"
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
+ """Gemini Omni 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 GeminiOmniClient
14
+
15
+ __all__ = [
16
+ "GeminiOmniClient",
17
+ "AuthenticationError",
18
+ "RateLimitError",
19
+ "InsufficientCreditsError",
20
+ "NotFoundError",
21
+ "ValidationError",
22
+ "TaskFailedError",
23
+ "TaskTimeoutError",
24
+ ]
@@ -0,0 +1,29 @@
1
+ """Gemini Omni 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.create_audio import CreateAudio
10
+ from .resources.create_character import CreateCharacter
11
+ from .resources.text_to_video import TextToVideo
12
+
13
+
14
+ class GeminiOmniClient:
15
+ """Gemini Omni client: reusable voices, characters, and text-to-video.
16
+
17
+ Example::
18
+
19
+ client = GeminiOmniClient(api_key="sk-...")
20
+ result = client.text_to_video.run(prompt="A fox in snow", duration_seconds=8)
21
+ """
22
+
23
+ def __init__(self, api_key: Optional[str] = None, **options: Any) -> None:
24
+ resolved_api_key = resolve_api_key(api_key)
25
+ client_options = ClientOptions(api_key=resolved_api_key, **options)
26
+ http = client_options.http_client or HttpClient(client_options)
27
+ self.create_audio = CreateAudio(http)
28
+ self.create_character = CreateCharacter(http)
29
+ self.text_to_video = TextToVideo(http)
@@ -0,0 +1,5 @@
1
+ from .create_audio import CreateAudio
2
+ from .create_character import CreateCharacter
3
+ from .text_to_video import TextToVideo
4
+
5
+ __all__ = ["CreateAudio", "CreateCharacter", "TextToVideo"]
@@ -0,0 +1,60 @@
1
+ """Gemini Omni create-audio resource (synchronous)."""
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 CreateAudioResponse
10
+
11
+
12
+ class CreateAudio(Resource):
13
+ """Create a reusable voice. Synchronous: ``run()`` returns the result directly."""
14
+
15
+ ENDPOINT = "/api/v1/gemini_omni/create_audio"
16
+
17
+ RESPONSE_CLASS = CreateAudioResponse
18
+
19
+ NAME_MAX_LENGTH = 210
20
+ VOICE_DESCRIPTION_MAX_LENGTH = 20_000
21
+ EXAMPLE_DIALOGUE_MAX_LENGTH = 120
22
+
23
+ def run(self, **params: Any) -> Any:
24
+ """Create a reusable voice (synchronous).
25
+
26
+ Args:
27
+ **params: Reusable voice parameters (audio_id, name, ...).
28
+
29
+ Returns:
30
+ The result.
31
+ """
32
+ compacted = self._compact_params(params)
33
+ self._validate_params(compacted)
34
+ return self._request("post", self.ENDPOINT, body=compacted)
35
+
36
+ def _validate_params(self, params: Dict[str, Any]) -> None:
37
+ self._validate_required(params, "audio_id")
38
+ self._validate_required(params, "name")
39
+ self._validate_length(params, "name", self.NAME_MAX_LENGTH)
40
+ self._validate_length(params, "voice_description", self.VOICE_DESCRIPTION_MAX_LENGTH)
41
+ self._validate_length(params, "example_dialogue", self.EXAMPLE_DIALOGUE_MAX_LENGTH)
42
+
43
+ @staticmethod
44
+ def _validate_required(params: Dict[str, Any], key: str) -> None:
45
+ value = params.get(key)
46
+ if isinstance(value, list):
47
+ present = len(value) > 0
48
+ elif isinstance(value, str):
49
+ present = value != ""
50
+ else:
51
+ present = value is not None
52
+ if not present:
53
+ raise ValidationError(f"{key} is required")
54
+
55
+ @staticmethod
56
+ def _validate_length(params: Dict[str, Any], key: str, max_length: int) -> None:
57
+ value = params.get(key)
58
+ if value is None or len(str(value)) <= max_length:
59
+ return
60
+ raise ValidationError(f"{key} must be at most {max_length} characters")
@@ -0,0 +1,65 @@
1
+ """Gemini Omni create-character resource (synchronous)."""
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 CreateCharacterResponse
10
+
11
+
12
+ class CreateCharacter(Resource):
13
+ """Create a reusable character. Synchronous: ``run()`` returns the result directly."""
14
+
15
+ ENDPOINT = "/api/v1/gemini_omni/create_character"
16
+
17
+ RESPONSE_CLASS = CreateCharacterResponse
18
+
19
+ DESCRIPTIONS_MAX_LENGTH = 20_000
20
+ CHARACTER_NAME_MAX_LENGTH = 210
21
+
22
+ def run(self, **params: Any) -> Any:
23
+ """Create a reusable character (synchronous).
24
+
25
+ Args:
26
+ **params: Reusable character parameters (descriptions, reference_image_url, ...).
27
+
28
+ Returns:
29
+ The result.
30
+ """
31
+ compacted = self._compact_params(params)
32
+ self._validate_params(compacted)
33
+ return self._request("post", self.ENDPOINT, body=compacted)
34
+
35
+ def _validate_params(self, params: Dict[str, Any]) -> None:
36
+ self._validate_required(params, "descriptions")
37
+ self._validate_required(params, "reference_image_url")
38
+ if params.get("audio_ids") is not None:
39
+ self._validate_array(params, "audio_ids")
40
+ self._validate_length(params, "descriptions", self.DESCRIPTIONS_MAX_LENGTH)
41
+ self._validate_length(params, "character_name", self.CHARACTER_NAME_MAX_LENGTH)
42
+
43
+ @staticmethod
44
+ def _validate_required(params: Dict[str, Any], key: str) -> None:
45
+ value = params.get(key)
46
+ if isinstance(value, list):
47
+ present = len(value) > 0
48
+ elif isinstance(value, str):
49
+ present = value != ""
50
+ else:
51
+ present = value is not None
52
+ if not present:
53
+ raise ValidationError(f"{key} is required")
54
+
55
+ @staticmethod
56
+ def _validate_array(params: Dict[str, Any], key: str) -> None:
57
+ if not isinstance(params.get(key), list):
58
+ raise ValidationError(f"{key} must be an array")
59
+
60
+ @staticmethod
61
+ def _validate_length(params: Dict[str, Any], key: str, max_length: int) -> None:
62
+ value = params.get(key)
63
+ if value is None or len(str(value)) <= max_length:
64
+ return
65
+ raise ValidationError(f"{key} must be at most {max_length} characters")
@@ -0,0 +1,177 @@
1
+ """Gemini Omni text-to-video resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from runapi.core import Resource, ValidationError
8
+
9
+ from ..types import (
10
+ ASPECT_RATIOS,
11
+ DURATIONS,
12
+ OUTPUT_RESOLUTIONS,
13
+ SEED_MAX,
14
+ SEED_MIN,
15
+ CompletedTextToVideoResponse,
16
+ TextToVideoResponse,
17
+ )
18
+
19
+
20
+ class TextToVideo(Resource):
21
+ """Generate video from a prompt, reference media, and characters."""
22
+
23
+ ENDPOINT = "/api/v1/gemini_omni/text_to_video"
24
+
25
+ RESPONSE_CLASS = TextToVideoResponse
26
+ COMPLETED_RESPONSE_CLASS = CompletedTextToVideoResponse
27
+
28
+ PROMPT_MAX_LENGTH = 20_000
29
+ REFERENCE_IMAGE_URLS_MAX = 7
30
+ AUDIO_IDS_MAX = 3
31
+ VIDEO_LIST_MAX = 1
32
+ CHARACTER_IDS_MAX = 3
33
+ REFERENCE_UNITS_MAX = 7
34
+ VIDEO_REFERENCE_UNITS = 2
35
+ MAX_TRIM_SECONDS = 10
36
+
37
+ def run(self, **params: Any) -> Any:
38
+ """Create a text-to-video task and poll until it completes.
39
+
40
+ Args:
41
+ **params: Text-to-video parameters (model, prompt, ...).
42
+
43
+ Returns:
44
+ The completed text-to-video response.
45
+ """
46
+ task = self.create(**params)
47
+ return self._poll_until_complete(lambda: self.get(task.id))
48
+
49
+ def create(self, **params: Any) -> Any:
50
+ """Create a text-to-video task and return immediately with an id.
51
+
52
+ Args:
53
+ **params: Text-to-video parameters (model, prompt, ...).
54
+
55
+ Returns:
56
+ The task creation result with an id.
57
+ """
58
+ compacted = self._compact_params(params)
59
+ self._validate_params(compacted)
60
+ return self._request("post", self.ENDPOINT, body=compacted)
61
+
62
+ def get(self, id: str) -> Any:
63
+ """Fetch the current status of a text-to-video task.
64
+
65
+ Args:
66
+ id: Task id.
67
+
68
+ Returns:
69
+ The current text-to-video status.
70
+ """
71
+ return self._request("get", f"{self.ENDPOINT}/{id}")
72
+
73
+ def _validate_params(self, params: Dict[str, Any]) -> None:
74
+ self._validate_required(params, "prompt")
75
+ self._validate_required(params, "duration_seconds")
76
+ self._validate_length(params, "prompt", self.PROMPT_MAX_LENGTH)
77
+ self._validate_optional(params, "duration_seconds", DURATIONS)
78
+ self._validate_optional(params, "aspect_ratio", ASPECT_RATIOS)
79
+ self._validate_optional(params, "output_resolution", OUTPUT_RESOLUTIONS)
80
+ if params.get("reference_image_urls") is not None:
81
+ self._validate_array(params, "reference_image_urls", self.REFERENCE_IMAGE_URLS_MAX)
82
+ if params.get("audio_ids") is not None:
83
+ self._validate_array(params, "audio_ids", self.AUDIO_IDS_MAX)
84
+ if params.get("video_list") is not None:
85
+ self._validate_array(params, "video_list", self.VIDEO_LIST_MAX)
86
+ if params.get("character_ids") is not None:
87
+ self._validate_array(params, "character_ids", self.CHARACTER_IDS_MAX)
88
+ if params.get("video_list") is not None:
89
+ self._validate_video_list(params.get("video_list"))
90
+ self._validate_reference_units(params)
91
+ self._validate_seed(params)
92
+
93
+ @staticmethod
94
+ def _validate_required(params: Dict[str, Any], key: str) -> None:
95
+ value = params.get(key)
96
+ if isinstance(value, list):
97
+ present = len(value) > 0
98
+ elif isinstance(value, str):
99
+ present = value != ""
100
+ else:
101
+ present = value is not None
102
+ if not present:
103
+ raise ValidationError(f"{key} is required")
104
+
105
+ @staticmethod
106
+ def _validate_length(params: Dict[str, Any], key: str, max_length: int) -> None:
107
+ value = params.get(key)
108
+ if value is None or len(str(value)) <= max_length:
109
+ return
110
+ raise ValidationError(f"{key} must be at most {max_length} characters")
111
+
112
+ @staticmethod
113
+ def _validate_array(params: Dict[str, Any], key: str, max_length: int) -> None:
114
+ value = params.get(key)
115
+ if not isinstance(value, list):
116
+ raise ValidationError(f"{key} must be an array")
117
+ if len(value) <= max_length:
118
+ return
119
+ raise ValidationError(f"{key} accepts at most {max_length} items")
120
+
121
+ def _validate_video_list(self, items: List[Dict[str, Any]]) -> None:
122
+ for index, item in enumerate(items):
123
+ url = item.get("url") if isinstance(item, dict) else None
124
+ if not url:
125
+ raise ValidationError(f"video_list[{index}].url is required")
126
+ start_time = self._to_float(item.get("start"))
127
+ end_time = self._to_float(item.get("ends"))
128
+ if start_time is None or end_time is None:
129
+ raise ValidationError(f"video_list[{index}] start and ends must be numbers")
130
+ if start_time < 0:
131
+ raise ValidationError(f"video_list[{index}].start must be 0 or greater")
132
+ if not end_time > start_time:
133
+ raise ValidationError(f"video_list[{index}].ends must be greater than start")
134
+ if (end_time - start_time) > self.MAX_TRIM_SECONDS:
135
+ raise ValidationError(
136
+ f"video_list[{index}] trim range must be {self.MAX_TRIM_SECONDS} seconds or less"
137
+ )
138
+
139
+ def _validate_reference_units(self, params: Dict[str, Any]) -> None:
140
+ units = (
141
+ self._count(params.get("reference_image_urls"))
142
+ + self._count(params.get("video_list")) * self.VIDEO_REFERENCE_UNITS
143
+ + self._count(params.get("character_ids"))
144
+ )
145
+ if units <= self.REFERENCE_UNITS_MAX:
146
+ return
147
+ raise ValidationError(
148
+ f"reference_image_urls + video_list*2 + character_ids must use {self.REFERENCE_UNITS_MAX} reference units or fewer"
149
+ )
150
+
151
+ @staticmethod
152
+ def _validate_seed(params: Dict[str, Any]) -> None:
153
+ value = params.get("seed")
154
+ if value is None:
155
+ return
156
+ try:
157
+ seed = int(value)
158
+ except (TypeError, ValueError):
159
+ seed = None
160
+ if seed is not None and SEED_MIN <= seed <= SEED_MAX:
161
+ return
162
+ raise ValidationError(f"seed must be an integer between {SEED_MIN} and {SEED_MAX}")
163
+
164
+ @staticmethod
165
+ def _count(value: Any) -> int:
166
+ if value is None:
167
+ return 0
168
+ return len(value) if isinstance(value, list) else 1
169
+
170
+ @staticmethod
171
+ def _to_float(value: Any) -> Optional[float]:
172
+ if isinstance(value, bool):
173
+ return None
174
+ try:
175
+ return float(value)
176
+ except (TypeError, ValueError):
177
+ return None
@@ -0,0 +1,62 @@
1
+ """Gemini Omni enums and response models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from runapi.core import BaseModel, TaskResponse, optional, required
6
+
7
+ AUDIO_VOICES = [
8
+ "achernar", "achird", "algenib", "algieba", "alnilam", "aoede", "autonoe",
9
+ "callirrhoe", "charon", "despina", "enceladus", "erinome", "fenrir", "gacrux",
10
+ "iapetus", "kore", "laomedeia", "leda", "orus", "puck", "pulcherrima",
11
+ "rasalgethi", "sadachbia", "sadaltager", "schedar", "sulafat", "umbriel",
12
+ "vindemiatrix", "zephyr", "zubenelgenubi",
13
+ ]
14
+
15
+ DURATIONS = [4, 6, 8, 10]
16
+ ASPECT_RATIOS = ["16:9", "9:16"]
17
+ OUTPUT_RESOLUTIONS = ["720p", "1080p", "4k"]
18
+ SEED_MIN = 0
19
+ SEED_MAX = 2_147_483_647
20
+
21
+
22
+ class Audio(BaseModel):
23
+ id = required(str)
24
+ name = optional(str)
25
+
26
+
27
+ class CreateAudioResponse(BaseModel):
28
+ """Result of creating a Gemini Omni reusable voice."""
29
+ id = required(str)
30
+ audio = optional(lambda: Audio)
31
+ error = optional(str)
32
+
33
+
34
+ class Image(BaseModel):
35
+ url = optional(str)
36
+
37
+
38
+ class Character(BaseModel):
39
+ id = required(str)
40
+ name = optional(str)
41
+ images = optional([lambda: Image])
42
+
43
+
44
+ class CreateCharacterResponse(BaseModel):
45
+ """Result of creating a Gemini Omni reusable character."""
46
+ id = required(str)
47
+ character = optional(lambda: Character)
48
+ error = optional(str)
49
+
50
+
51
+ class Video(BaseModel):
52
+ url = optional(str)
53
+
54
+
55
+ class TextToVideoResponse(TaskResponse):
56
+ """Task status/result for Gemini Omni text-to-video."""
57
+ videos = optional([lambda: Video])
58
+
59
+
60
+ class CompletedTextToVideoResponse(TextToVideoResponse):
61
+ """Narrowed Gemini Omni text-to-video response once polling observes completion."""
62
+ videos = required([lambda: Video])
@@ -0,0 +1,164 @@
1
+ import pytest
2
+
3
+ from runapi.core import config
4
+ from runapi.core.errors import AuthenticationError, ValidationError
5
+ from runapi.gemini_omni import GeminiOmniClient
6
+ from runapi.gemini_omni.resources.create_audio import CreateAudio
7
+ from runapi.gemini_omni.resources.create_character import CreateCharacter
8
+ from runapi.gemini_omni.resources.text_to_video import TextToVideo
9
+ from runapi.gemini_omni.types import (
10
+ CompletedTextToVideoResponse,
11
+ CreateAudioResponse,
12
+ CreateCharacterResponse,
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(GeminiOmniClient(api_key="k", http_client=FakeHttp()), GeminiOmniClient)
40
+
41
+
42
+ def test_falls_back_to_global(monkeypatch):
43
+ monkeypatch.setattr(config, "api_key", "global-key")
44
+ assert isinstance(GeminiOmniClient(http_client=FakeHttp()), GeminiOmniClient)
45
+
46
+
47
+ def test_falls_back_to_env(monkeypatch):
48
+ monkeypatch.setenv("RUNAPI_API_KEY", "env-key")
49
+ assert isinstance(GeminiOmniClient(http_client=FakeHttp()), GeminiOmniClient)
50
+
51
+
52
+ def test_raises_without_api_key():
53
+ with pytest.raises(AuthenticationError, match="API key is required"):
54
+ GeminiOmniClient()
55
+
56
+
57
+ def test_uses_injected_http_client_and_accessors():
58
+ fake = FakeHttp()
59
+ client = GeminiOmniClient(api_key="k", http_client=fake)
60
+ assert isinstance(client.create_audio, CreateAudio)
61
+ assert isinstance(client.create_character, CreateCharacter)
62
+ assert isinstance(client.text_to_video, TextToVideo)
63
+ assert client.text_to_video._http is fake
64
+
65
+
66
+ # --- synchronous resources ------------------------------------------------
67
+
68
+
69
+ def test_create_audio_posts_once_and_returns_typed():
70
+ fake = FakeHttp({"id": "a1", "audio": {"id": "kore", "name": "Narrator"}})
71
+ client = GeminiOmniClient(api_key="k", http_client=fake)
72
+ result = client.create_audio.run(audio_id="kore", name="Narrator")
73
+ assert fake.calls == [("post", "/api/v1/gemini_omni/create_audio", {"audio_id": "kore", "name": "Narrator"})]
74
+ assert isinstance(result, CreateAudioResponse)
75
+ assert result.audio.id == "kore"
76
+
77
+
78
+ def test_create_audio_requires_audio_id_and_name():
79
+ client = GeminiOmniClient(api_key="k", http_client=FakeHttp())
80
+ with pytest.raises(ValidationError, match="audio_id is required"):
81
+ client.create_audio.run(name="Narrator")
82
+ with pytest.raises(ValidationError, match="name is required"):
83
+ client.create_audio.run(audio_id="kore")
84
+
85
+
86
+ def test_create_audio_name_length():
87
+ client = GeminiOmniClient(api_key="k", http_client=FakeHttp())
88
+ with pytest.raises(ValidationError, match="name must be at most 210 characters"):
89
+ client.create_audio.run(audio_id="kore", name="x" * 211)
90
+
91
+
92
+ def test_create_character_returns_typed_and_validates():
93
+ fake = FakeHttp({"id": "c1", "character": {"id": "c1", "name": "Robot"}})
94
+ client = GeminiOmniClient(api_key="k", http_client=fake)
95
+ result = client.create_character.run(descriptions="A robot", reference_image_url="https://x/r.png")
96
+ assert fake.calls[0][0:2] == ("post", "/api/v1/gemini_omni/create_character")
97
+ assert isinstance(result, CreateCharacterResponse)
98
+ assert result.character.id == "c1"
99
+
100
+
101
+ def test_create_character_requires_fields():
102
+ client = GeminiOmniClient(api_key="k", http_client=FakeHttp())
103
+ with pytest.raises(ValidationError, match="descriptions is required"):
104
+ client.create_character.run(reference_image_url="https://x/r.png")
105
+ with pytest.raises(ValidationError, match="reference_image_url is required"):
106
+ client.create_character.run(descriptions="A robot")
107
+
108
+
109
+ def test_create_character_audio_ids_must_be_array():
110
+ client = GeminiOmniClient(api_key="k", http_client=FakeHttp())
111
+ with pytest.raises(ValidationError, match="audio_ids must be an array"):
112
+ client.create_character.run(
113
+ descriptions="A robot", reference_image_url="https://x/r.png", audio_ids="not-a-list"
114
+ )
115
+
116
+
117
+ # --- async text_to_video --------------------------------------------------
118
+
119
+
120
+ def test_text_to_video_create_and_get_shapes():
121
+ fake = FakeHttp({"id": "t1", "status": "pending"}, {"id": "t1", "status": "processing"})
122
+ client = GeminiOmniClient(api_key="k", http_client=fake)
123
+ client.text_to_video.create(prompt="a fox", duration_seconds=8)
124
+ client.text_to_video.get("t1")
125
+ assert fake.calls == [
126
+ ("post", "/api/v1/gemini_omni/text_to_video", {"prompt": "a fox", "duration_seconds": 8}),
127
+ ("get", "/api/v1/gemini_omni/text_to_video/t1", None),
128
+ ]
129
+
130
+
131
+ def test_text_to_video_requires_prompt_and_duration():
132
+ client = GeminiOmniClient(api_key="k", http_client=FakeHttp())
133
+ with pytest.raises(ValidationError, match="prompt is required"):
134
+ client.text_to_video.create(duration_seconds=8)
135
+ with pytest.raises(ValidationError, match="duration_seconds is required"):
136
+ client.text_to_video.create(prompt="a fox")
137
+
138
+
139
+ def test_text_to_video_duration_enum():
140
+ client = GeminiOmniClient(api_key="k", http_client=FakeHttp())
141
+ with pytest.raises(ValidationError, match="Invalid duration_seconds"):
142
+ client.text_to_video.create(prompt="a fox", duration_seconds=7)
143
+
144
+
145
+ def test_text_to_video_reference_units_cap():
146
+ client = GeminiOmniClient(api_key="k", http_client=FakeHttp())
147
+ with pytest.raises(ValidationError, match="reference units"):
148
+ client.text_to_video.create(
149
+ prompt="a fox",
150
+ duration_seconds=8,
151
+ reference_image_urls=["a", "b", "c", "d", "e"], # 5, within array max (7)
152
+ character_ids=["x", "y", "z"], # 3, within array max (3); 5 + 3 = 8 > 7 units
153
+ )
154
+
155
+
156
+ def test_text_to_video_run_narrows_completed():
157
+ fake = FakeHttp(
158
+ {"id": "t1", "status": "pending"},
159
+ {"id": "t1", "status": "completed", "videos": [{"url": "https://x/v.mp4"}]},
160
+ )
161
+ client = GeminiOmniClient(api_key="k", http_client=fake)
162
+ result = client.text_to_video.run(prompt="a fox", duration_seconds=8)
163
+ assert isinstance(result, CompletedTextToVideoResponse)
164
+ assert result.videos[0].url == "https://x/v.mp4"