mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
feat(providers): support DeepSeek V4 vision
This commit is contained in:
@@ -114,6 +114,9 @@ _KIMI_SERVER_MANAGED_TEMPERATURE_MODELS: frozenset[str] = frozenset({
|
|||||||
"kimi-k2.5",
|
"kimi-k2.5",
|
||||||
"kimi-k2.6",
|
"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)
|
_TEXT_TOOL_CALL_RE = re.compile(r"<tool_call>\s*(.*?)\s*</tool_call>", re.DOTALL)
|
||||||
# Thinking-capable MiMo models per Xiaomi docs (see
|
# Thinking-capable MiMo models per Xiaomi docs (see
|
||||||
# tests/providers/test_xiaomi_mimo_thinking.py). mimo-v2-flash is omitted
|
# tests/providers/test_xiaomi_mimo_thinking.py). mimo-v2-flash is omitted
|
||||||
@@ -678,12 +681,20 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
dumped = str(content)
|
dumped = str(content)
|
||||||
return dumped or "(empty)"
|
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."""
|
"""Strip non-standard keys, normalize tool_call IDs."""
|
||||||
sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS)
|
sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS)
|
||||||
id_map: dict[str, str] = {}
|
id_map: dict[str, str] = {}
|
||||||
pending_tool_ids: dict[str, deque[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()
|
normalize_tool_ids = self._should_normalize_tool_call_ids()
|
||||||
strip_reasoning = bool(
|
strip_reasoning = bool(
|
||||||
self._spec
|
self._spec
|
||||||
@@ -910,7 +921,10 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
|
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
"model": model_name,
|
"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
|
# 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."""
|
"""Build a Responses API body for direct OpenAI requests."""
|
||||||
model_name = model or self.default_model
|
model_name = model or self.default_model
|
||||||
model_name = self._request_model_name(model_name)
|
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 = (
|
sanitized_state = (
|
||||||
provider_context.conversation_state
|
provider_context.conversation_state
|
||||||
if provider_context is not None
|
if provider_context is not None
|
||||||
@@ -1234,7 +1251,8 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
if sanitized_state is not None:
|
if sanitized_state is not None:
|
||||||
sanitized_state = sanitized_state.with_pending_messages(
|
sanitized_state = sanitized_state.with_pending_messages(
|
||||||
self._sanitize_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")
|
is_deepseek = bool(self._spec and self._spec.name == "deepseek")
|
||||||
|
|||||||
@@ -493,7 +493,11 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
backend="openai_compat",
|
backend="openai_compat",
|
||||||
default_api_base="https://api.deepseek.com",
|
default_api_base="https://api.deepseek.com",
|
||||||
thinking_style="thinking_type",
|
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",),
|
responses_default_tools=("web_search",),
|
||||||
),
|
),
|
||||||
# Gemini: Google's OpenAI-compatible endpoint
|
# Gemini: Google's OpenAI-compatible endpoint
|
||||||
|
|||||||
@@ -368,6 +368,46 @@ async def test_deepseek_v4_pro_uses_responses_api() -> None:
|
|||||||
assert "include" not in call_kwargs
|
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.asyncio
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("provider_name", "model"),
|
("provider_name", "model"),
|
||||||
@@ -1557,6 +1597,33 @@ def test_deepseek_coerces_list_content_to_string() -> None:
|
|||||||
assert "world" in kw["messages"][0]["content"]
|
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:
|
def test_non_deepseek_keeps_list_content() -> None:
|
||||||
"""Only DeepSeek should force string content; OpenAI-compatible providers keep blocks."""
|
"""Only DeepSeek should force string content; OpenAI-compatible providers keep blocks."""
|
||||||
spec = find_by_name("openai")
|
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
|
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):
|
def test_deepseek_v4_models_use_responses_by_model(provider, model):
|
||||||
provider._spec = find_by_name("deepseek")
|
provider._spec = find_by_name("deepseek")
|
||||||
provider._effective_base = "https://api.deepseek.com"
|
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
|
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):
|
def test_deepseek_v4_models_match_provider_prefixed_model(provider, model):
|
||||||
provider._spec = find_by_name("deepseek")
|
provider._spec = find_by_name("deepseek")
|
||||||
provider._effective_base = "https://api.deepseek.com"
|
provider._effective_base = "https://api.deepseek.com"
|
||||||
|
|||||||
Reference in New Issue
Block a user