From e6587a8d8e374dd36624ce8ab3fc9a3d9f45daf0 Mon Sep 17 00:00:00 2001 From: Haisam Abbas Date: Wed, 20 May 2026 12:18:18 +0500 Subject: [PATCH 1/6] Fix image mime detection for MiniMax --- tests/providers/test_image_generation.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/providers/test_image_generation.py b/tests/providers/test_image_generation.py index 3bee376d8..eea3a3fe6 100644 --- a/tests/providers/test_image_generation.py +++ b/tests/providers/test_image_generation.py @@ -390,6 +390,17 @@ async def test_minimax_payload_and_response_with_reference_image(tmp_path: Path) assert body["subject_reference"][0]["image_file"].startswith("data:image/png;base64,") +@pytest.mark.asyncio +async def test_minimax_base64_response_uses_detected_mime() -> None: + raw_b64 = base64.b64encode(JPEG_BYTES).decode("ascii") + fake = FakeClient(FakeResponse({"data": {"image_base64": [raw_b64]}})) + client = MiniMaxImageGenerationClient(api_key="sk-mm-test", client=fake) # type: ignore[arg-type] + + response = await client.generate(prompt="draw", model="image-01") + + assert response.images == [f"data:image/jpeg;base64,{raw_b64}"] + + # --------------------------------------------------------------------------- # StepFun (阶跃星辰) # --------------------------------------------------------------------------- From 72f999f8f7a8e8984c7ee2c1ccd50d1f8b3cf419 Mon Sep 17 00:00:00 2001 From: Haisam Abbas Date: Wed, 20 May 2026 13:56:43 +0500 Subject: [PATCH 2/6] refactor image provider HTTP handling --- nanobot/providers/image_generation.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/nanobot/providers/image_generation.py b/nanobot/providers/image_generation.py index 501b98fd2..b267f5c00 100644 --- a/nanobot/providers/image_generation.py +++ b/nanobot/providers/image_generation.py @@ -219,7 +219,10 @@ class ImageGenerationProvider(ABC): *, headers: dict[str, str], body: dict[str, Any], + client: httpx.AsyncClient | None = None, ) -> httpx.Response: + if client is not None: + return await client.post(url, headers=headers, json=body) if self._client is not None: return await self._client.post(url, headers=headers, json=body) async with httpx.AsyncClient(timeout=self.timeout) as c: @@ -390,10 +393,11 @@ class AIHubMixImageGenerationClient(ImageGenerationProvider): model_path = _aihubmix_model_path(model) url = f"{self.api_base}/models/{model_path}/predictions" try: - response = await client.post( + response = await self._http_post( url, headers={**headers, "Content-Type": "application/json"}, - json=body, + body=body, + client=client, ) except httpx.TimeoutException as exc: raise ImageGenerationError("AIHubMix image generation timed out") from exc @@ -706,22 +710,16 @@ class MiniMaxImageGenerationClient(ImageGenerationProvider): body.update(self.extra_body) - client = self._client or httpx.AsyncClient(timeout=self.timeout) - try: - return await self._generate_with_client(client, body, headers) - finally: - if self._client is None: - await client.aclose() + return await self._generate_with_client(body, headers) async def _generate_with_client( self, - client: httpx.AsyncClient, body: dict[str, Any], headers: dict[str, str], ) -> GeneratedImageResponse: url = f"{self.api_base}/image_generation" try: - response = await client.post(url, headers=headers, json=body) + response = await self._http_post(url, headers=headers, body=body) except httpx.TimeoutException as exc: raise ImageGenerationError("MiniMax image generation timed out") from exc except httpx.RequestError as exc: From a7b34422f3c61c78e66cda21c5c520298808af57 Mon Sep 17 00:00:00 2001 From: Haisam Abbas Date: Wed, 20 May 2026 14:06:55 +0500 Subject: [PATCH 3/6] fix Gemini image base and provider docs --- nanobot/providers/image_generation.py | 25 ++++++++++++++---------- tests/providers/test_image_generation.py | 5 +++++ tests/providers/test_litellm_kwargs.py | 6 ++++++ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/nanobot/providers/image_generation.py b/nanobot/providers/image_generation.py index b267f5c00..8f25195cf 100644 --- a/nanobot/providers/image_generation.py +++ b/nanobot/providers/image_generation.py @@ -129,6 +129,11 @@ _IMAGE_GEN_PROVIDERS: dict[str, type[ImageGenerationProvider]] = {} def register_image_gen_provider(cls: type[ImageGenerationProvider]) -> None: + """Register an image provider at import time only. + + The registry is populated by module side effects so provider discovery + stays lazy and consistent across the process. + """ name = cls.provider_name if not name: raise ValueError(f"{cls.__name__} must set provider_name") @@ -204,7 +209,7 @@ class ImageGenerationProvider(ABC): image_size: str | None = None, ) -> GeneratedImageResponse: ... - def _require_images(self, images: list[str], data: dict[str, Any]) -> None: + def _ensure_images(self, images: list[str], data: dict[str, Any]) -> None: if images: return provider_error = data.get("error") if isinstance(data, dict) else None @@ -311,7 +316,7 @@ class OpenRouterImageGenerationClient(ImageGenerationProvider): if isinstance(url_value, str) and url_value.startswith("data:image/"): images.append(url_value) - self._require_images(images, data) + self._ensure_images(images, data) return GeneratedImageResponse( images=images, @@ -413,7 +418,7 @@ class AIHubMixImageGenerationClient(ImageGenerationProvider): payload = response.json() images = await _aihubmix_images_from_payload(client, payload) - self._require_images(images, payload) + self._ensure_images(images, payload) return GeneratedImageResponse(images=images, content="", raw=payload) @@ -446,9 +451,9 @@ class GeminiImageGenerationClient(ImageGenerationProvider): return "https://generativelanguage.googleapis.com/v1beta" def _resolve_base_url(self, api_base: str | None) -> str: - # The Gemini provider's registry default_api_base is the OpenAI-compat - # shim (.../v1beta/openai/), which has no image endpoints. - # Skip the registry lookup and use the native API base directly. + # Gemini chat completions use the registry's OpenAI-compatible shim. + # Image generation must hit the native Generative Language API, so we + # intentionally bypass the shared registry lookup here. if api_base: return api_base.rstrip("/") return self._default_base_url() @@ -522,7 +527,7 @@ class GeminiImageGenerationClient(ImageGenerationProvider): if isinstance(b64, str) and b64: images.append(f"data:{mime};base64,{b64}") - self._require_images(images, data) + self._ensure_images(images, data) return GeneratedImageResponse(images=images, content="", raw=data) @@ -580,7 +585,7 @@ class GeminiImageGenerationClient(ImageGenerationProvider): if b64: images.append(f"data:{mime};base64,{b64}") - self._require_images(images, data) + self._ensure_images(images, data) return GeneratedImageResponse( images=images, @@ -734,7 +739,7 @@ class MiniMaxImageGenerationClient(ImageGenerationProvider): payload = response.json() images = _minimax_images_from_payload(payload) - self._require_images(images, payload) + self._ensure_images(images, payload) return GeneratedImageResponse(images=images, content="", raw=payload) @@ -840,7 +845,7 @@ class StepFunImageGenerationClient(ImageGenerationProvider): payload = response.json() images = _stepfun_images_from_payload(payload) - self._require_images(images, payload) + self._ensure_images(images, payload) return GeneratedImageResponse(images=images, content="", raw=payload) diff --git a/tests/providers/test_image_generation.py b/tests/providers/test_image_generation.py index eea3a3fe6..c42d947d5 100644 --- a/tests/providers/test_image_generation.py +++ b/tests/providers/test_image_generation.py @@ -348,6 +348,11 @@ async def test_gemini_requires_api_key() -> None: await client.generate(prompt="draw", model="imagen-4.0-generate-001") +def test_gemini_image_client_uses_native_api_base_by_default() -> None: + client = GeminiImageGenerationClient(api_key="AIza-test") + assert client.api_base == "https://generativelanguage.googleapis.com/v1beta" + + @pytest.mark.asyncio async def test_gemini_no_images_raises() -> None: fake = FakeClient(FakeResponse({"candidates": [{"content": {"parts": [{"text": "sorry"}]}}]})) diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py index 5f2ffec59..76414ad35 100644 --- a/tests/providers/test_litellm_kwargs.py +++ b/tests/providers/test_litellm_kwargs.py @@ -449,6 +449,12 @@ def test_gemma_routes_to_gemini_provider() -> None: assert "gemma" in spec.keywords +def test_gemini_spec_keeps_openai_compat_base() -> None: + spec = find_by_name("gemini") + assert spec is not None + assert spec.default_api_base == "https://generativelanguage.googleapis.com/v1beta/openai/" + + async def test_openrouter_sets_default_attribution_headers() -> None: spec = find_by_name("openrouter") with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_cls: From 65cecc01fbed9b6358d7c93b16301a4b979ee4e4 Mon Sep 17 00:00:00 2001 From: Haisam Abbas Date: Wed, 20 May 2026 17:16:53 +0500 Subject: [PATCH 4/6] fix shell guard url path detection --- nanobot/agent/tools/shell.py | 2 +- tests/tools/test_tool_validation.py | 36 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 0252b9746..7e0ef57a8 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -418,7 +418,7 @@ class ExecTool(Tool): # Windows: match drive-root paths like `C:\` as well as `C:\path\to\file`, and UNC paths like `\\server\share` # NOTE: `*` is required so `C:\` (nothing after the slash) is still extracted. win_paths = re.findall( - r"(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)", + r"(?<;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)", command ) posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only diff --git a/tests/tools/test_tool_validation.py b/tests/tools/test_tool_validation.py index 42620dcc6..188a8952f 100644 --- a/tests/tools/test_tool_validation.py +++ b/tests/tools/test_tool_validation.py @@ -3,6 +3,8 @@ import subprocess import sys from typing import Any +import pytest + from nanobot.agent.tools import ( ArraySchema, IntegerSchema, @@ -15,6 +17,7 @@ from nanobot.agent.tools import ( from nanobot.agent.tools.base import Tool from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.shell import ExecTool +from nanobot.security.network import configure_ssrf_whitelist class SampleTool(Tool): @@ -218,6 +221,39 @@ def test_exec_extract_absolute_paths_ignores_relative_posix_segments() -> None: assert "/bin/python" not in paths +def test_exec_extract_absolute_paths_ignores_urls() -> None: + cmd = 'curl -s -o /dev/null -w "%{http_code}" https://www.google.com' + paths = ExecTool._extract_absolute_paths(cmd) + assert paths == ["/dev/null"] + + +@pytest.mark.parametrize( + "command", + [ + 'curl -s -o /dev/null -w "%{http_code}" https://www.google.com', + 'wget -q -O - http://example.com 2>&1 | head -c 100', + 'python3 -c "import urllib.request; print(urllib.request.urlopen(\'http://example.com\').read()[:100])"', + ], +) +def test_exec_guard_allows_public_urls(tmp_path, command: str) -> None: + tool = ExecTool(restrict_to_workspace=True) + error = tool._guard_command(command, str(tmp_path)) + assert error is None + + +def test_exec_guard_allows_whitelisted_internal_urls(tmp_path) -> None: + configure_ssrf_whitelist(["10.10.10.0/24"]) + try: + tool = ExecTool(restrict_to_workspace=True) + error = tool._guard_command( + 'curl -s -H "Authorization: Bearer ..." http://10.10.10.3:8123/api/', + str(tmp_path), + ) + assert error is None + finally: + configure_ssrf_whitelist([]) + + def test_exec_extract_absolute_paths_captures_posix_absolute_paths() -> None: cmd = "cat /tmp/data.txt > /tmp/out.txt" paths = ExecTool._extract_absolute_paths(cmd) From 3f789bd9f913f3feb5bf360fe8a9f280e9dfc6ff Mon Sep 17 00:00:00 2001 From: Haisam Abbas Date: Wed, 20 May 2026 17:21:34 +0500 Subject: [PATCH 5/6] Revert "fix shell guard url path detection" This reverts commit 65cecc01fbed9b6358d7c93b16301a4b979ee4e4. --- nanobot/agent/tools/shell.py | 2 +- tests/tools/test_tool_validation.py | 36 ----------------------------- 2 files changed, 1 insertion(+), 37 deletions(-) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 7e0ef57a8..0252b9746 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -418,7 +418,7 @@ class ExecTool(Tool): # Windows: match drive-root paths like `C:\` as well as `C:\path\to\file`, and UNC paths like `\\server\share` # NOTE: `*` is required so `C:\` (nothing after the slash) is still extracted. win_paths = re.findall( - r"(?<;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)", + r"(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)", command ) posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only diff --git a/tests/tools/test_tool_validation.py b/tests/tools/test_tool_validation.py index 188a8952f..42620dcc6 100644 --- a/tests/tools/test_tool_validation.py +++ b/tests/tools/test_tool_validation.py @@ -3,8 +3,6 @@ import subprocess import sys from typing import Any -import pytest - from nanobot.agent.tools import ( ArraySchema, IntegerSchema, @@ -17,7 +15,6 @@ from nanobot.agent.tools import ( from nanobot.agent.tools.base import Tool from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.shell import ExecTool -from nanobot.security.network import configure_ssrf_whitelist class SampleTool(Tool): @@ -221,39 +218,6 @@ def test_exec_extract_absolute_paths_ignores_relative_posix_segments() -> None: assert "/bin/python" not in paths -def test_exec_extract_absolute_paths_ignores_urls() -> None: - cmd = 'curl -s -o /dev/null -w "%{http_code}" https://www.google.com' - paths = ExecTool._extract_absolute_paths(cmd) - assert paths == ["/dev/null"] - - -@pytest.mark.parametrize( - "command", - [ - 'curl -s -o /dev/null -w "%{http_code}" https://www.google.com', - 'wget -q -O - http://example.com 2>&1 | head -c 100', - 'python3 -c "import urllib.request; print(urllib.request.urlopen(\'http://example.com\').read()[:100])"', - ], -) -def test_exec_guard_allows_public_urls(tmp_path, command: str) -> None: - tool = ExecTool(restrict_to_workspace=True) - error = tool._guard_command(command, str(tmp_path)) - assert error is None - - -def test_exec_guard_allows_whitelisted_internal_urls(tmp_path) -> None: - configure_ssrf_whitelist(["10.10.10.0/24"]) - try: - tool = ExecTool(restrict_to_workspace=True) - error = tool._guard_command( - 'curl -s -H "Authorization: Bearer ..." http://10.10.10.3:8123/api/', - str(tmp_path), - ) - assert error is None - finally: - configure_ssrf_whitelist([]) - - def test_exec_extract_absolute_paths_captures_posix_absolute_paths() -> None: cmd = "cat /tmp/data.txt > /tmp/out.txt" paths = ExecTool._extract_absolute_paths(cmd) From c1073f298647e69b766448466024e1ef3d609413 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Fri, 22 May 2026 22:19:32 +0800 Subject: [PATCH 6/6] fix(image-generation): keep image presence helper stable --- nanobot/providers/image_generation.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/nanobot/providers/image_generation.py b/nanobot/providers/image_generation.py index 8adde0f55..1316f1d43 100644 --- a/nanobot/providers/image_generation.py +++ b/nanobot/providers/image_generation.py @@ -219,7 +219,7 @@ class ImageGenerationProvider(ABC): image_size: str | None = None, ) -> GeneratedImageResponse: ... - def _ensure_images(self, images: list[str], data: dict[str, Any]) -> None: + def _require_images(self, images: list[str], data: dict[str, Any]) -> None: if images: return provider_error = data.get("error") if isinstance(data, dict) else None @@ -326,7 +326,7 @@ class OpenRouterImageGenerationClient(ImageGenerationProvider): if isinstance(url_value, str) and url_value.startswith("data:image/"): images.append(url_value) - self._ensure_images(images, data) + self._require_images(images, data) return GeneratedImageResponse( images=images, @@ -428,7 +428,7 @@ class AIHubMixImageGenerationClient(ImageGenerationProvider): payload = response.json() images = await _aihubmix_images_from_payload(client, payload) - self._ensure_images(images, payload) + self._require_images(images, payload) return GeneratedImageResponse(images=images, content="", raw=payload) @@ -670,7 +670,7 @@ class GeminiImageGenerationClient(ImageGenerationProvider): if isinstance(b64, str) and b64: images.append(f"data:{mime};base64,{b64}") - self._ensure_images(images, data) + self._require_images(images, data) return GeneratedImageResponse(images=images, content="", raw=data) @@ -728,7 +728,7 @@ class GeminiImageGenerationClient(ImageGenerationProvider): if b64: images.append(f"data:{mime};base64,{b64}") - self._ensure_images(images, data) + self._require_images(images, data) return GeneratedImageResponse( images=images, @@ -882,7 +882,7 @@ class MiniMaxImageGenerationClient(ImageGenerationProvider): payload = response.json() images = _minimax_images_from_payload(payload) - self._ensure_images(images, payload) + self._require_images(images, payload) return GeneratedImageResponse(images=images, content="", raw=payload) @@ -1408,7 +1408,7 @@ class StepFunImageGenerationClient(ImageGenerationProvider): payload = response.json() images = _stepfun_images_from_payload(payload) - self._ensure_images(images, payload) + self._require_images(images, payload) return GeneratedImageResponse(images=images, content="", raw=payload)