feat(providers): support DeepSeek V4 vision

This commit is contained in:
chengyongru
2026-08-21 23:18:09 +08:00
committed by chengyongru
parent 9bc9b94732
commit 9ae3ae17da
4 changed files with 103 additions and 8 deletions
+23 -5
View File
@@ -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"<tool_call>\s*(.*?)\s*</tool_call>", 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")
+5 -1
View File
@@ -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
+67
View File
@@ -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")
@@ -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"