diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index fd8fb7d76..61649d9ac 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -114,6 +114,9 @@ _KIMI_SERVER_MANAGED_TEMPERATURE_MODELS: frozenset[str] = frozenset({ "kimi-k2.5", "kimi-k2.6", }) +_DEEPSEEK_MULTIMODAL_MODELS: frozenset[str] = frozenset({ + "deepseek-v4-flash-vision-exp", +}) _TEXT_TOOL_CALL_RE = re.compile(r"\s*(.*?)\s*", re.DOTALL) # Thinking-capable MiMo models per Xiaomi docs (see # tests/providers/test_xiaomi_mimo_thinking.py). mimo-v2-flash is omitted @@ -678,12 +681,20 @@ class OpenAICompatProvider(LLMProvider): dumped = str(content) return dumped or "(empty)" - def _sanitize_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + def _sanitize_messages( + self, + messages: list[dict[str, Any]], + model: str | None = None, + ) -> list[dict[str, Any]]: """Strip non-standard keys, normalize tool_call IDs.""" sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS) id_map: dict[str, str] = {} pending_tool_ids: dict[str, deque[str]] = {} - force_string_content = bool(self._spec and self._spec.name == "deepseek") + is_deepseek = bool(self._spec and self._spec.name == "deepseek") + model_name = model or self.default_model + force_string_content = ( + is_deepseek and _model_slug(model_name) not in _DEEPSEEK_MULTIMODAL_MODELS + ) normalize_tool_ids = self._should_normalize_tool_call_ids() strip_reasoning = bool( self._spec @@ -910,7 +921,10 @@ class OpenAICompatProvider(LLMProvider): kwargs: dict[str, Any] = { "model": model_name, - "messages": self._sanitize_messages(self._sanitize_empty_content(messages)), + "messages": self._sanitize_messages( + self._sanitize_empty_content(messages), + model_name, + ), } # GPT-5 and reasoning models (o1/o3/o4) reject temperature when @@ -1225,7 +1239,10 @@ class OpenAICompatProvider(LLMProvider): """Build a Responses API body for direct OpenAI requests.""" model_name = model or self.default_model model_name = self._request_model_name(model_name) - sanitized_messages = self._sanitize_messages(self._sanitize_empty_content(messages)) + sanitized_messages = self._sanitize_messages( + self._sanitize_empty_content(messages), + model_name, + ) sanitized_state = ( provider_context.conversation_state if provider_context is not None @@ -1234,7 +1251,8 @@ class OpenAICompatProvider(LLMProvider): if sanitized_state is not None: sanitized_state = sanitized_state.with_pending_messages( self._sanitize_messages( - self._sanitize_empty_content(sanitized_state.pending_messages) + self._sanitize_empty_content(sanitized_state.pending_messages), + model_name, ) ) is_deepseek = bool(self._spec and self._spec.name == "deepseek") diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 291fa2c2c..46d414012 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -493,7 +493,11 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( backend="openai_compat", default_api_base="https://api.deepseek.com", thinking_style="thinking_type", - responses_models=("deepseek-v4-flash", "deepseek-v4-pro"), + responses_models=( + "deepseek-v4-flash", + "deepseek-v4-pro", + "deepseek-v4-flash-vision-exp", + ), responses_default_tools=("web_search",), ), # Gemini: Google's OpenAI-compatible endpoint diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py index 7c8c4de20..c53c2830d 100644 --- a/tests/providers/test_litellm_kwargs.py +++ b/tests/providers/test_litellm_kwargs.py @@ -368,6 +368,46 @@ async def test_deepseek_v4_pro_uses_responses_api() -> None: assert "include" not in call_kwargs +@pytest.mark.asyncio +async def test_deepseek_vision_uses_responses_api_with_image_input() -> None: + mock_chat = AsyncMock(return_value=_fake_chat_response()) + mock_responses = AsyncMock(return_value=_fake_responses_response("vision response")) + content = [ + {"type": "text", "text": "describe this image"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}, + ] + + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_class: + client_instance = mock_client_class.return_value + client_instance.chat.completions.create = mock_chat + client_instance.responses.create = mock_responses + + provider = OpenAICompatProvider( + api_key="sk-test-key", + default_model="deepseek-v4-flash-vision-exp", + spec=find_by_name("deepseek"), + ) + result = await provider.chat( + messages=[{"role": "user", "content": content}], + model="deepseek-v4-flash-vision-exp", + ) + + assert result.content == "vision response" + mock_chat.assert_not_awaited() + call_kwargs = mock_responses.call_args.kwargs + assert call_kwargs["input"] == [{ + "role": "user", + "content": [ + {"type": "input_text", "text": "describe this image"}, + { + "type": "input_image", + "image_url": "data:image/png;base64,AA==", + "detail": "auto", + }, + ], + }] + + @pytest.mark.asyncio @pytest.mark.parametrize( ("provider_name", "model"), @@ -1557,6 +1597,33 @@ def test_deepseek_coerces_list_content_to_string() -> None: assert "world" in kw["messages"][0]["content"] +def test_deepseek_vision_preserves_multimodal_content() -> None: + """DeepSeek's vision model requires OpenAI-compatible content blocks.""" + spec = find_by_name("deepseek") + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + p = OpenAICompatProvider( + api_key="k", + default_model="deepseek-v4-flash-vision-exp", + spec=spec, + ) + content = [ + {"type": "text", "text": "describe this image"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}, + ] + + kw = p._build_kwargs( + messages=[{"role": "user", "content": content}], + tools=None, + model="deepseek-v4-flash-vision-exp", + max_tokens=1024, + temperature=0.7, + reasoning_effort=None, + tool_choice=None, + ) + + assert kw["messages"][0]["content"] == content + + def test_non_deepseek_keeps_list_content() -> None: """Only DeepSeek should force string content; OpenAI-compatible providers keep blocks.""" spec = find_by_name("openai") diff --git a/tests/providers/test_responses_circuit_breaker.py b/tests/providers/test_responses_circuit_breaker.py index 21beee0ff..a29e607da 100644 --- a/tests/providers/test_responses_circuit_breaker.py +++ b/tests/providers/test_responses_circuit_breaker.py @@ -31,7 +31,10 @@ def test_responses_api_available_by_default(provider): assert provider._should_use_responses_api("gpt-5", None) is True -@pytest.mark.parametrize("model", ["deepseek-v4-flash", "deepseek-v4-pro"]) +@pytest.mark.parametrize( + "model", + ["deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4-flash-vision-exp"], +) def test_deepseek_v4_models_use_responses_by_model(provider, model): provider._spec = find_by_name("deepseek") provider._effective_base = "https://api.deepseek.com" @@ -41,7 +44,10 @@ def test_deepseek_v4_models_use_responses_by_model(provider, model): assert provider._should_use_responses_api("deepseek-chat", None) is False -@pytest.mark.parametrize("model", ["deepseek-v4-flash", "deepseek-v4-pro"]) +@pytest.mark.parametrize( + "model", + ["deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4-flash-vision-exp"], +) def test_deepseek_v4_models_match_provider_prefixed_model(provider, model): provider._spec = find_by_name("deepseek") provider._effective_base = "https://api.deepseek.com"