codex-ai 0.2.2__tar.gz → 0.2.3__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.
Files changed (66) hide show
  1. {codex_ai-0.2.2 → codex_ai-0.2.3}/CHANGELOG.md +9 -0
  2. {codex_ai-0.2.2 → codex_ai-0.2.3}/PKG-INFO +11 -8
  3. {codex_ai-0.2.2 → codex_ai-0.2.3}/README.md +7 -4
  4. {codex_ai-0.2.2 → codex_ai-0.2.3}/docs/en/architecture/providers/README.md +2 -1
  5. {codex_ai-0.2.2 → codex_ai-0.2.3}/docs/en/architecture/providers/data_flow.md +2 -1
  6. {codex_ai-0.2.2 → codex_ai-0.2.3}/docs/ru/architecture/providers/README.md +2 -1
  7. {codex_ai-0.2.2 → codex_ai-0.2.3}/docs/ru/architecture/providers/data_flow.md +2 -1
  8. {codex_ai-0.2.2 → codex_ai-0.2.3}/pyproject.toml +1 -1
  9. {codex_ai-0.2.2 → codex_ai-0.2.3}/src/codex_ai/core/dispatcher.py +2 -0
  10. {codex_ai-0.2.2 → codex_ai-0.2.3}/src/codex_ai/core/protocol.py +3 -0
  11. {codex_ai-0.2.2 → codex_ai-0.2.3}/src/codex_ai/providers/gemini.py +59 -12
  12. {codex_ai-0.2.2 → codex_ai-0.2.3}/tests/unit/core/test_dispatcher.py +6 -2
  13. {codex_ai-0.2.2 → codex_ai-0.2.3}/tests/unit/core/test_protocol.py +1 -0
  14. {codex_ai-0.2.2 → codex_ai-0.2.3}/tests/unit/providers/test_gemini_provider.py +30 -1
  15. {codex_ai-0.2.2 → codex_ai-0.2.3}/uv.lock +6 -6
  16. {codex_ai-0.2.2 → codex_ai-0.2.3}/.github/workflows/ci.yml +0 -0
  17. {codex_ai-0.2.2 → codex_ai-0.2.3}/.github/workflows/docs.yml +0 -0
  18. {codex_ai-0.2.2 → codex_ai-0.2.3}/.github/workflows/publish.yml +0 -0
  19. {codex_ai-0.2.2 → codex_ai-0.2.3}/.gitignore +0 -0
  20. {codex_ai-0.2.2 → codex_ai-0.2.3}/.nojekyll +0 -0
  21. {codex_ai-0.2.2 → codex_ai-0.2.3}/.pre-commit-config.yaml +0 -0
  22. {codex_ai-0.2.2 → codex_ai-0.2.3}/.python-version +0 -0
  23. {codex_ai-0.2.2 → codex_ai-0.2.3}/.secrets.baseline +0 -0
  24. {codex_ai-0.2.2 → codex_ai-0.2.3}/LICENSE +0 -0
  25. {codex_ai-0.2.2 → codex_ai-0.2.3}/docs/changelog.md +0 -0
  26. {codex_ai-0.2.2 → codex_ai-0.2.3}/docs/en/api/core/dispatcher.md +0 -0
  27. {codex_ai-0.2.2 → codex_ai-0.2.3}/docs/en/api/core/exceptions.md +0 -0
  28. {codex_ai-0.2.2 → codex_ai-0.2.3}/docs/en/api/core/protocol.md +0 -0
  29. {codex_ai-0.2.2 → codex_ai-0.2.3}/docs/en/api/core/router.md +0 -0
  30. {codex_ai-0.2.2 → codex_ai-0.2.3}/docs/en/api/core/sync.md +0 -0
  31. {codex_ai-0.2.2 → codex_ai-0.2.3}/docs/en/api/index.md +0 -0
  32. {codex_ai-0.2.2 → codex_ai-0.2.3}/docs/en/api/providers/gemini.md +0 -0
  33. {codex_ai-0.2.2 → codex_ai-0.2.3}/docs/en/api/providers/openai.md +0 -0
  34. {codex_ai-0.2.2 → codex_ai-0.2.3}/docs/en/architecture/core/README.md +0 -0
  35. {codex_ai-0.2.2 → codex_ai-0.2.3}/docs/en/architecture/core/data_flow.md +0 -0
  36. {codex_ai-0.2.2 → codex_ai-0.2.3}/docs/index.md +0 -0
  37. {codex_ai-0.2.2 → codex_ai-0.2.3}/docs/ru/architecture/core/README.md +0 -0
  38. {codex_ai-0.2.2 → codex_ai-0.2.3}/docs/ru/architecture/core/data_flow.md +0 -0
  39. {codex_ai-0.2.2 → codex_ai-0.2.3}/docs/stylesheets/extra.css +0 -0
  40. {codex_ai-0.2.2 → codex_ai-0.2.3}/mkdocs.yml +0 -0
  41. {codex_ai-0.2.2 → codex_ai-0.2.3}/src/codex_ai/__init__.py +0 -0
  42. {codex_ai-0.2.2 → codex_ai-0.2.3}/src/codex_ai/core/__init__.py +0 -0
  43. {codex_ai-0.2.2 → codex_ai-0.2.3}/src/codex_ai/core/exceptions.py +0 -0
  44. {codex_ai-0.2.2 → codex_ai-0.2.3}/src/codex_ai/core/router.py +0 -0
  45. {codex_ai-0.2.2 → codex_ai-0.2.3}/src/codex_ai/core/sync.py +0 -0
  46. {codex_ai-0.2.2 → codex_ai-0.2.3}/src/codex_ai/providers/__init__.py +0 -0
  47. {codex_ai-0.2.2 → codex_ai-0.2.3}/src/codex_ai/providers/openai.py +0 -0
  48. {codex_ai-0.2.2 → codex_ai-0.2.3}/src/codex_ai/py.typed +0 -0
  49. {codex_ai-0.2.2 → codex_ai-0.2.3}/tests/conftest.py +0 -0
  50. {codex_ai-0.2.2 → codex_ai-0.2.3}/tests/integration/__init__.py +0 -0
  51. {codex_ai-0.2.2 → codex_ai-0.2.3}/tests/integration/conftest.py +0 -0
  52. {codex_ai-0.2.2 → codex_ai-0.2.3}/tests/integration/test_providers_integration.py +0 -0
  53. {codex_ai-0.2.2 → codex_ai-0.2.3}/tests/unit/__init__.py +0 -0
  54. {codex_ai-0.2.2 → codex_ai-0.2.3}/tests/unit/conftest.py +0 -0
  55. {codex_ai-0.2.2 → codex_ai-0.2.3}/tests/unit/core/__init__.py +0 -0
  56. {codex_ai-0.2.2 → codex_ai-0.2.3}/tests/unit/core/test_exceptions.py +0 -0
  57. {codex_ai-0.2.2 → codex_ai-0.2.3}/tests/unit/core/test_router.py +0 -0
  58. {codex_ai-0.2.2 → codex_ai-0.2.3}/tests/unit/core/test_sync.py +0 -0
  59. {codex_ai-0.2.2 → codex_ai-0.2.3}/tests/unit/providers/__init__.py +0 -0
  60. {codex_ai-0.2.2 → codex_ai-0.2.3}/tests/unit/providers/test_openai_provider.py +0 -0
  61. {codex_ai-0.2.2 → codex_ai-0.2.3}/tests/unit/test_public_api.py +0 -0
  62. {codex_ai-0.2.2 → codex_ai-0.2.3}/tools/__init__.py +0 -0
  63. {codex_ai-0.2.2 → codex_ai-0.2.3}/tools/dev/README.md +0 -0
  64. {codex_ai-0.2.2 → codex_ai-0.2.3}/tools/dev/__init__.py +0 -0
  65. {codex_ai-0.2.2 → codex_ai-0.2.3}/tools/dev/check.py +0 -0
  66. {codex_ai-0.2.2 → codex_ai-0.2.3}/tools/dev/generate_project_tree.py +0 -0
@@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.3] - 2026-05-17
8
+
9
+ ### Added
10
+ - Added explicit Gemini image `image_config` support for aspect ratio and image size controls.
11
+ - Added a Gemini image retry from `image_size="4K"` to `image_size="2K"` when the initial 4K request is rejected.
12
+
13
+ ### Changed
14
+ - Updated the Gemini extra to `google-genai==2.3.0` for the SDK image configuration contract.
15
+
7
16
  ## [0.2.2] - 2026-05-15
8
17
 
9
18
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-ai
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Gemini-first and OpenAI provider helpers for Codex
5
5
  Project-URL: Homepage, https://github.com/codexdlc/codex-ai
6
6
  Project-URL: Documentation, https://codexdlc.github.io/codex-ai/
@@ -19,12 +19,12 @@ Requires-Python: >=3.12
19
19
  Requires-Dist: codex-core<0.4.0,>=0.2.2
20
20
  Requires-Dist: pydantic<3.0,>=2.0
21
21
  Provides-Extra: all
22
- Requires-Dist: google-genai==1.68.0; extra == 'all'
22
+ Requires-Dist: google-genai==2.3.0; extra == 'all'
23
23
  Requires-Dist: openai<2.0,>=1.0; extra == 'all'
24
24
  Provides-Extra: dev
25
25
  Requires-Dist: bandit>=1.7; extra == 'dev'
26
26
  Requires-Dist: detect-secrets>=1.5; extra == 'dev'
27
- Requires-Dist: google-genai==1.68.0; extra == 'dev'
27
+ Requires-Dist: google-genai==2.3.0; extra == 'dev'
28
28
  Requires-Dist: mypy>=1.10; extra == 'dev'
29
29
  Requires-Dist: openai<2.0,>=1.0; extra == 'dev'
30
30
  Requires-Dist: pip-audit>=2.7; extra == 'dev'
@@ -40,7 +40,7 @@ Requires-Dist: mkdocs-material>=9.0; extra == 'docs'
40
40
  Requires-Dist: mkdocs>=1.5; extra == 'docs'
41
41
  Requires-Dist: mkdocstrings[python]>=0.24; extra == 'docs'
42
42
  Provides-Extra: gemini
43
- Requires-Dist: google-genai==1.68.0; extra == 'gemini'
43
+ Requires-Dist: google-genai==2.3.0; extra == 'gemini'
44
44
  Provides-Extra: openai
45
45
  Requires-Dist: openai<2.0,>=1.0; extra == 'openai'
46
46
  Description-Content-Type: text/markdown
@@ -83,9 +83,10 @@ gemini = GeminiProvider(api_key="AIza...")
83
83
  text = await gemini.generate_text("Write one short tavern rumor.")
84
84
  loot = await gemini.generate_json("Create one loot item.", schema=LootItem)
85
85
  image_bytes, content_type = await gemini.generate_image_bytes(
86
- "A fantasy clan banner, game icon style.",
87
- model="gemini-2.5-flash-image",
88
- response_mime_type="image/webp",
86
+ "Square tactical dark fantasy ruined capital city map, no labels.",
87
+ model="gemini-3-pro-image-preview",
88
+ response_mime_type="image/png",
89
+ image_config={"aspect_ratio": "1:1", "image_size": "4K"},
89
90
  )
90
91
 
91
92
  imagen_bytes, imagen_content_type = await gemini.generate_imagen_bytes(
@@ -98,7 +99,9 @@ imagen_bytes, imagen_content_type = await gemini.generate_imagen_bytes(
98
99
 
99
100
  `generate_image_bytes()` targets Gemini image models through `generate_content` and treats
100
101
  `response_mime_type` as a preferred/fallback MIME type. It does not pass image MIME values
101
- to Gemini's text `response_mime_type` config field. Use `generate_imagen_bytes()` for
102
+ to Gemini's text `response_mime_type` config field. Pass Gemini image controls such as
103
+ `aspect_ratio` and `image_size` with `image_config`; if a `4K` request is rejected,
104
+ the Gemini provider retries once with `2K`. Use `generate_imagen_bytes()` for
102
105
  Imagen models; that path uses `generate_images` and passes the requested MIME as
103
106
  `output_mime_type`.
104
107
 
@@ -36,9 +36,10 @@ gemini = GeminiProvider(api_key="AIza...")
36
36
  text = await gemini.generate_text("Write one short tavern rumor.")
37
37
  loot = await gemini.generate_json("Create one loot item.", schema=LootItem)
38
38
  image_bytes, content_type = await gemini.generate_image_bytes(
39
- "A fantasy clan banner, game icon style.",
40
- model="gemini-2.5-flash-image",
41
- response_mime_type="image/webp",
39
+ "Square tactical dark fantasy ruined capital city map, no labels.",
40
+ model="gemini-3-pro-image-preview",
41
+ response_mime_type="image/png",
42
+ image_config={"aspect_ratio": "1:1", "image_size": "4K"},
42
43
  )
43
44
 
44
45
  imagen_bytes, imagen_content_type = await gemini.generate_imagen_bytes(
@@ -51,7 +52,9 @@ imagen_bytes, imagen_content_type = await gemini.generate_imagen_bytes(
51
52
 
52
53
  `generate_image_bytes()` targets Gemini image models through `generate_content` and treats
53
54
  `response_mime_type` as a preferred/fallback MIME type. It does not pass image MIME values
54
- to Gemini's text `response_mime_type` config field. Use `generate_imagen_bytes()` for
55
+ to Gemini's text `response_mime_type` config field. Pass Gemini image controls such as
56
+ `aspect_ratio` and `image_size` with `image_config`; if a `4K` request is rejected,
57
+ the Gemini provider retries once with `2K`. Use `generate_imagen_bytes()` for
55
58
  Imagen models; that path uses `generate_images` and passes the requested MIME as
56
59
  `output_mime_type`.
57
60
 
@@ -47,6 +47,7 @@ LLMRouter builder -> PromptResult -> LLMDispatcher.process() -> provider.answer(
47
47
  - Gemini-specific capabilities are represented directly instead of being hidden behind a broad universal abstraction.
48
48
  - JSON generation uses provider-native JSON configuration and still validates locally with `json.loads` and optional Pydantic models.
49
49
  - Gemini image generation and Imagen generation are separate explicit methods because they use different SDK calls.
50
- - `generate_image_bytes()` uses Gemini `generate_content` with image modality. Its `response_mime_type` is only a preferred/fallback MIME type.
50
+ - `generate_image_bytes()` uses Gemini `generate_content` with image modality. Its `response_mime_type` is only a preferred/fallback MIME type, and Gemini image controls are passed through `image_config`.
51
+ - `generate_image_bytes()` retries a rejected `image_config={"image_size": "4K"}` request once as `2K`.
51
52
  - `generate_imagen_bytes()` uses Imagen `generate_images` and passes the requested MIME as `output_mime_type`.
52
53
  - Anthropic, OpenRouter, and multi-provider failover are not active APIs in this alpha line.
@@ -28,12 +28,13 @@ PromptResult | str
28
28
  ```
29
29
  prompt: str
30
30
  -> GeminiProvider.generate_image_bytes()
31
- -> GenerateContentConfig(response_modalities=[IMAGE])
31
+ -> GenerateContentConfig(response_modalities=[IMAGE], image_config=...)
32
32
  -> first inline_data image part
33
33
  -> (bytes, actual_mime_type)
34
34
  ```
35
35
 
36
36
  `response_mime_type` is not passed to `GenerateContentConfig.response_mime_type` on this path; it is only a fallback content type when Gemini omits `inline_data.mime_type`.
37
+ When `image_config.image_size` is `4K` and Gemini rejects the request, the provider retries once with `image_size` changed to `2K`.
37
38
 
38
39
  ## Imagen Images
39
40
 
@@ -47,6 +47,7 @@ LLMRouter builder -> PromptResult -> LLMDispatcher.process() -> provider.answer(
47
47
  - Возможности Gemini представлены напрямую, без широкой универсальной абстракции.
48
48
  - JSON generation использует native JSON config провайдера и локальную проверку через `json.loads` и optional Pydantic schema.
49
49
  - Gemini image generation и Imagen generation разведены в отдельные явные методы, потому что они используют разные SDK calls.
50
- - `generate_image_bytes()` использует Gemini `generate_content` с image modality. Его `response_mime_type` является только preferred/fallback MIME type.
50
+ - `generate_image_bytes()` использует Gemini `generate_content` с image modality. Его `response_mime_type` является только preferred/fallback MIME type, а Gemini image controls передаются через `image_config`.
51
+ - `generate_image_bytes()` один раз повторяет отклоненный `image_config={"image_size": "4K"}` запрос как `2K`.
51
52
  - `generate_imagen_bytes()` использует Imagen `generate_images` и передает requested MIME как `output_mime_type`.
52
53
  - Anthropic, OpenRouter и multi-provider failover не являются активными API в этой alpha-линейке.
@@ -28,12 +28,13 @@ PromptResult | str
28
28
  ```
29
29
  prompt: str
30
30
  -> GeminiProvider.generate_image_bytes()
31
- -> GenerateContentConfig(response_modalities=[IMAGE])
31
+ -> GenerateContentConfig(response_modalities=[IMAGE], image_config=...)
32
32
  -> first inline_data image part
33
33
  -> (bytes, actual_mime_type)
34
34
  ```
35
35
 
36
36
  `response_mime_type` в этом пути не передается в `GenerateContentConfig.response_mime_type`; он используется только как fallback content type, если Gemini не вернул `inline_data.mime_type`.
37
+ Если `image_config.image_size` равен `4K` и Gemini отклоняет запрос, провайдер один раз повторяет его с `image_size="2K"`.
37
38
 
38
39
  ## Imagen Images
39
40
 
@@ -31,7 +31,7 @@ Issues = "https://github.com/codexdlc/codex-ai/issues"
31
31
 
32
32
  [project.optional-dependencies]
33
33
  openai = ["openai>=1.0,<2.0"]
34
- gemini = ["google-genai==1.68.0"]
34
+ gemini = ["google-genai==2.3.0"]
35
35
  all = [
36
36
  "codex-ai[openai,gemini]",
37
37
  ]
@@ -96,6 +96,7 @@ class LLMDispatcher:
96
96
  *,
97
97
  model: str | None = None,
98
98
  response_mime_type: str = "image/webp",
99
+ image_config: dict[str, Any] | None = None,
99
100
  **kwargs: Any,
100
101
  ) -> tuple[bytes, str]:
101
102
  """
@@ -114,6 +115,7 @@ class LLMDispatcher:
114
115
  prompt,
115
116
  model=model,
116
117
  response_mime_type=response_mime_type,
118
+ image_config=image_config,
117
119
  **kwargs,
118
120
  )
119
121
 
@@ -148,6 +148,7 @@ class ImageGenerationProvider(Protocol):
148
148
  *,
149
149
  model: str | None = None,
150
150
  response_mime_type: str = "image/webp",
151
+ image_config: dict[str, Any] | None = None,
151
152
  **kwargs: Any,
152
153
  ) -> tuple[bytes, str]:
153
154
  """
@@ -157,6 +158,8 @@ class ImageGenerationProvider(Protocol):
157
158
  prompt: Plain image-generation prompt.
158
159
  model: Optional image model override.
159
160
  response_mime_type: Requested/preferred image MIME type.
161
+ image_config: Optional image generation controls such as
162
+ ``{"aspect_ratio": "1:1", "image_size": "4K"}``.
160
163
  **kwargs: Extra provider-specific kwargs.
161
164
 
162
165
  Returns:
@@ -186,6 +186,7 @@ class GeminiProvider:
186
186
  *,
187
187
  model: str | None = None,
188
188
  response_mime_type: str = "image/webp",
189
+ image_config: dict[str, Any] | None = None,
189
190
  **kwargs: Any,
190
191
  ) -> tuple[bytes, str]:
191
192
  """
@@ -206,29 +207,75 @@ class GeminiProvider:
206
207
  runtime_kw = kwargs.copy()
207
208
  runtime_kw.pop("model", None)
208
209
  runtime_kw.pop("response_mime_type", None)
210
+ runtime_kw.pop("image_config", None)
209
211
 
210
- config = genai_types.GenerateContentConfig(
211
- response_modalities=[genai_types.Modality.IMAGE],
212
+ config_kwargs: dict[str, Any] = {
213
+ "response_modalities": [genai_types.Modality.IMAGE],
212
214
  **runtime_kw,
213
- )
215
+ }
216
+ if image_config is not None:
217
+ config_kwargs["image_config"] = image_config
214
218
 
215
219
  try:
216
- response = await self._client.aio.models.generate_content(
220
+ return await self._generate_image_once(
217
221
  model=selected_model,
218
- contents=prompt,
219
- config=config,
222
+ prompt=prompt,
223
+ config_kwargs=config_kwargs,
224
+ fallback_mime=requested_mime,
220
225
  )
221
- image = self._extract_first_inline_image(response, fallback_mime=requested_mime)
222
- if image is not None:
223
- return image
224
-
225
- detail = self._describe_non_image_response(response)
226
- raise LLMProviderError(f"Gemini image generation did not return image data{detail}")
227
226
  except LLMProviderError:
228
227
  raise
229
228
  except Exception as exc:
229
+ fallback_config_kwargs = self._fallback_4k_image_config_to_2k(config_kwargs)
230
+ if fallback_config_kwargs is not None:
231
+ try:
232
+ return await self._generate_image_once(
233
+ model=selected_model,
234
+ prompt=prompt,
235
+ config_kwargs=fallback_config_kwargs,
236
+ fallback_mime=requested_mime,
237
+ )
238
+ except LLMProviderError:
239
+ raise
240
+ except Exception as fallback_exc:
241
+ raise LLMProviderError(f"Gemini image generation error: {fallback_exc}") from fallback_exc
242
+
230
243
  raise LLMProviderError(f"Gemini image generation error: {exc}") from exc
231
244
 
245
+ async def _generate_image_once(
246
+ self,
247
+ *,
248
+ model: str,
249
+ prompt: str,
250
+ config_kwargs: dict[str, Any],
251
+ fallback_mime: str,
252
+ ) -> tuple[bytes, str]:
253
+ config = genai_types.GenerateContentConfig(**config_kwargs)
254
+ response = await self._client.aio.models.generate_content(
255
+ model=model,
256
+ contents=prompt,
257
+ config=config,
258
+ )
259
+ image = self._extract_first_inline_image(response, fallback_mime=fallback_mime)
260
+ if image is not None:
261
+ return image
262
+
263
+ detail = self._describe_non_image_response(response)
264
+ raise LLMProviderError(f"Gemini image generation did not return image data{detail}")
265
+
266
+ @staticmethod
267
+ def _fallback_4k_image_config_to_2k(config_kwargs: dict[str, Any]) -> dict[str, Any] | None:
268
+ image_config = config_kwargs.get("image_config")
269
+ if not isinstance(image_config, dict) or image_config.get("image_size") != "4K":
270
+ return None
271
+
272
+ fallback_image_config = image_config.copy()
273
+ fallback_image_config["image_size"] = "2K"
274
+
275
+ fallback_config_kwargs = config_kwargs.copy()
276
+ fallback_config_kwargs["image_config"] = fallback_image_config
277
+ return fallback_config_kwargs
278
+
232
279
  async def generate_imagen_bytes(
233
280
  self,
234
281
  prompt: str,
@@ -126,9 +126,10 @@ class ImageProvider:
126
126
  *,
127
127
  model: str | None = None,
128
128
  response_mime_type: str = "image/webp",
129
+ image_config: dict | None = None,
129
130
  **kwargs,
130
131
  ) -> tuple[bytes, str]:
131
- self.calls.append((prompt, model, response_mime_type, kwargs))
132
+ self.calls.append((prompt, model, response_mime_type, image_config, kwargs))
132
133
  return b"image-bytes", "image/png"
133
134
 
134
135
  async def generate_imagen_bytes(
@@ -159,11 +160,14 @@ async def test_dispatcher_generate_image_bytes_delegates_to_image_provider():
159
160
  "draw a castle",
160
161
  model="gemini-image",
161
162
  response_mime_type="image/webp",
163
+ image_config={"aspect_ratio": "1:1", "image_size": "4K"},
162
164
  seed=123,
163
165
  )
164
166
 
165
167
  assert result == (b"image-bytes", "image/png")
166
- assert provider.calls == [("draw a castle", "gemini-image", "image/webp", {"seed": 123})]
168
+ assert provider.calls == [
169
+ ("draw a castle", "gemini-image", "image/webp", {"aspect_ratio": "1:1", "image_size": "4K"}, {"seed": 123})
170
+ ]
167
171
 
168
172
 
169
173
  async def test_dispatcher_generate_image_bytes_raises_for_unsupported_provider(mock_provider):
@@ -106,6 +106,7 @@ def test_image_generation_provider_structural_check():
106
106
  *,
107
107
  model: str | None = None,
108
108
  response_mime_type: str = "image/webp",
109
+ image_config: dict | None = None,
109
110
  **kwargs,
110
111
  ) -> tuple[bytes, str]:
111
112
  return b"image", response_mime_type
@@ -418,14 +418,43 @@ async def test_gemini_generate_image_bytes_config_requests_image_modality_not_te
418
418
  with patch("codex_ai.providers.gemini.genai_types") as mock_types:
419
419
  mock_types.Modality.IMAGE = "IMAGE"
420
420
  mock_types.GenerateContentConfig.return_value = MagicMock()
421
- await provider.generate_image_bytes("draw a castle", response_mime_type="image/webp", seed=7)
421
+ await provider.generate_image_bytes(
422
+ "draw a castle",
423
+ response_mime_type="image/png",
424
+ image_config={"aspect_ratio": "1:1", "image_size": "4K"},
425
+ seed=7,
426
+ )
422
427
 
423
428
  config_kwargs = mock_types.GenerateContentConfig.call_args.kwargs
424
429
  assert config_kwargs["response_modalities"] == ["IMAGE"]
425
430
  assert "response_mime_type" not in config_kwargs
431
+ assert config_kwargs["image_config"] == {"aspect_ratio": "1:1", "image_size": "4K"}
426
432
  assert config_kwargs["seed"] == 7
427
433
 
428
434
 
435
+ async def test_gemini_generate_image_bytes_falls_back_from_4k_to_2k_when_config_rejected():
436
+ provider, mock_generate, _ = _make_provider()
437
+ mock_generate.side_effect = [
438
+ ValueError("unsupported image_size"),
439
+ _image_response(data=b"png", mime_type="image/png"),
440
+ ]
441
+
442
+ with patch("codex_ai.providers.gemini.genai_types") as mock_types:
443
+ mock_types.Modality.IMAGE = "IMAGE"
444
+ mock_types.GenerateContentConfig.side_effect = lambda **kwargs: kwargs
445
+ result = await provider.generate_image_bytes(
446
+ "draw a castle",
447
+ response_mime_type="image/png",
448
+ image_config={"aspect_ratio": "1:1", "image_size": "4K"},
449
+ )
450
+
451
+ assert result == (b"png", "image/png")
452
+ first_config = mock_generate.call_args_list[0].kwargs["config"]
453
+ second_config = mock_generate.call_args_list[1].kwargs["config"]
454
+ assert first_config["image_config"] == {"aspect_ratio": "1:1", "image_size": "4K"}
455
+ assert second_config["image_config"] == {"aspect_ratio": "1:1", "image_size": "2K"}
456
+
457
+
429
458
  async def test_gemini_generate_image_bytes_returns_inline_image_bytes_and_actual_mime():
430
459
  provider, mock_generate, _ = _make_provider()
431
460
  mock_generate.return_value = _image_response(data=b"png-bytes", mime_type="image/png")
@@ -344,9 +344,9 @@ requires-dist = [
344
344
  { name = "bandit", marker = "extra == 'dev'", specifier = ">=1.7" },
345
345
  { name = "codex-core", specifier = ">=0.2.2,<0.4.0" },
346
346
  { name = "detect-secrets", marker = "extra == 'dev'", specifier = ">=1.5" },
347
- { name = "google-genai", marker = "extra == 'all'", specifier = "==1.68.0" },
348
- { name = "google-genai", marker = "extra == 'dev'", specifier = "==1.68.0" },
349
- { name = "google-genai", marker = "extra == 'gemini'", specifier = "==1.68.0" },
347
+ { name = "google-genai", marker = "extra == 'all'", specifier = "==2.3.0" },
348
+ { name = "google-genai", marker = "extra == 'dev'", specifier = "==2.3.0" },
349
+ { name = "google-genai", marker = "extra == 'gemini'", specifier = "==2.3.0" },
350
350
  { name = "mike", marker = "extra == 'docs'", specifier = ">=2.0" },
351
351
  { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.5" },
352
352
  { name = "mkdocs-include-markdown-plugin", marker = "extra == 'docs'" },
@@ -622,7 +622,7 @@ requests = [
622
622
 
623
623
  [[package]]
624
624
  name = "google-genai"
625
- version = "1.68.0"
625
+ version = "2.3.0"
626
626
  source = { registry = "https://pypi.org/simple" }
627
627
  dependencies = [
628
628
  { name = "anyio" },
@@ -636,9 +636,9 @@ dependencies = [
636
636
  { name = "typing-extensions" },
637
637
  { name = "websockets" },
638
638
  ]
639
- sdist = { url = "https://files.pythonhosted.org/packages/9c/2c/f059982dbcb658cc535c81bbcbe7e2c040d675f4b563b03cdb01018a4bc3/google_genai-1.68.0.tar.gz", hash = "sha256:ac30c0b8bc630f9372993a97e4a11dae0e36f2e10d7c55eacdca95a9fa14ca96", size = 511285, upload-time = "2026-03-18T01:03:18.243Z" }
639
+ sdist = { url = "https://files.pythonhosted.org/packages/02/8e/dfa4b34dd4c0baffccf6466fc68d6d35011662d43e7d79accb902320db74/google_genai-2.3.0.tar.gz", hash = "sha256:e877c750a4ccacdd9928fc3aa8ca8820ce85cade0ca51bd83feceacf5959b579", size = 546930, upload-time = "2026-05-15T06:22:36.264Z" }
640
640
  wheels = [
641
- { url = "https://files.pythonhosted.org/packages/84/de/7d3ee9c94b74c3578ea4f88d45e8de9405902f857932334d81e89bce3dfa/google_genai-1.68.0-py3-none-any.whl", hash = "sha256:a1bc9919c0e2ea2907d1e319b65471d3d6d58c54822039a249fe1323e4178d15", size = 750912, upload-time = "2026-03-18T01:03:15.983Z" },
641
+ { url = "https://files.pythonhosted.org/packages/b4/6e/aa6b30b09f58b946750fc4089c5248fbd3576f746e0e818d88633559dc84/google_genai-2.3.0-py3-none-any.whl", hash = "sha256:89d3c71c9f5f5b931b405b88a5837aea2bd4d27ed90323b9599f5760bbb91d92", size = 805484, upload-time = "2026-05-15T06:22:34.247Z" },
642
642
  ]
643
643
 
644
644
  [[package]]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes