fix(llm): use native langchain-deepseek SDK (#349)
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
"""DeepSeek chat model integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.language_models import LanguageModelInput
|
||||
from langchain_core.messages import AIMessage, BaseMessage
|
||||
from langchain_deepseek import ChatDeepSeek
|
||||
|
||||
from .openai_compat import OpenAICompatContentMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEEPSEEK_THINKING_DISABLED = {"type": "disabled"}
|
||||
|
||||
|
||||
def is_deepseek_thinking_disabled(
|
||||
extra_body: Mapping[str, object] | None,
|
||||
) -> bool:
|
||||
"""Return whether a request body explicitly disables DeepSeek thinking."""
|
||||
if not extra_body:
|
||||
return False
|
||||
thinking = extra_body.get("thinking")
|
||||
return isinstance(thinking, Mapping) and thinking.get("type") == "disabled"
|
||||
|
||||
|
||||
def _inject_reasoning_content(
|
||||
messages: list[BaseMessage],
|
||||
payload: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
"""Copy captured DeepSeek reasoning into serialized assistant messages."""
|
||||
reasoning = [
|
||||
message.additional_kwargs.get("reasoning_content")
|
||||
for message in messages
|
||||
if isinstance(message, AIMessage)
|
||||
]
|
||||
serialized = payload.get("messages")
|
||||
if not isinstance(serialized, list):
|
||||
return payload
|
||||
|
||||
ai_index = 0
|
||||
for message in serialized:
|
||||
if not isinstance(message, dict) or message.get("role") != "assistant":
|
||||
continue
|
||||
value = reasoning[ai_index] if ai_index < len(reasoning) else None
|
||||
if value:
|
||||
message["reasoning_content"] = value
|
||||
elif "reasoning_content" not in message:
|
||||
message["reasoning_content"] = ""
|
||||
ai_index += 1
|
||||
return payload
|
||||
|
||||
|
||||
class EvoChatDeepSeek(OpenAICompatContentMixin, ChatDeepSeek):
|
||||
"""ChatDeepSeek with EvoScientist's media and history compatibility."""
|
||||
|
||||
def _get_request_payload(
|
||||
self,
|
||||
input_: LanguageModelInput,
|
||||
*,
|
||||
stop: list[str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
payload = super()._get_request_payload(input_, stop=stop, **kwargs)
|
||||
if is_deepseek_thinking_disabled(self.extra_body):
|
||||
return payload
|
||||
|
||||
try:
|
||||
messages = self._convert_input(input_).to_messages()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"DeepSeek reasoning passback: input conversion failed",
|
||||
exc_info=True,
|
||||
)
|
||||
return payload
|
||||
|
||||
return _inject_reasoning_content(messages, payload)
|
||||
@@ -200,16 +200,22 @@ def _provider_from_model(model: Any) -> str | None:
|
||||
(``ErrorNormalizationMiddleware``) then passes the exception
|
||||
through unchanged.
|
||||
"""
|
||||
cls_module = type(model).__module__ or ""
|
||||
if cls_module.startswith("langchain_openrouter"):
|
||||
cls_modules = {cls.__module__ for cls in type(model).__mro__}
|
||||
|
||||
def _uses_sdk(module_prefix: str) -> bool:
|
||||
return any(module.startswith(module_prefix) for module in cls_modules)
|
||||
|
||||
if _uses_sdk("langchain_openrouter"):
|
||||
return "openrouter"
|
||||
if cls_module.startswith("langchain_google_genai"):
|
||||
if _uses_sdk("langchain_google_genai"):
|
||||
return "google_genai"
|
||||
if cls_module.startswith("langchain_openai"):
|
||||
if _uses_sdk("langchain_deepseek"):
|
||||
return "deepseek"
|
||||
if _uses_sdk("langchain_openai"):
|
||||
return _lookup_host_or_compat(
|
||||
getattr(model, "openai_api_base", None), module_tag="openai"
|
||||
)
|
||||
if cls_module.startswith("langchain_anthropic"):
|
||||
if _uses_sdk("langchain_anthropic"):
|
||||
return _lookup_host_or_compat(
|
||||
getattr(model, "anthropic_api_url", None), module_tag="anthropic"
|
||||
)
|
||||
|
||||
+40
-13
@@ -15,6 +15,7 @@ import subprocess
|
||||
import warnings
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from langchain.chat_models import init_chat_model
|
||||
|
||||
@@ -24,10 +25,10 @@ from ..config.settings import (
|
||||
OPENROUTER_DEFAULT_HTTP_REFERER,
|
||||
)
|
||||
from .context_window import apply_known_context_window
|
||||
from .deepseek import EvoChatDeepSeek
|
||||
from .patches import (
|
||||
_is_ccproxy_codex,
|
||||
_patch_ccproxy_system_to_developer,
|
||||
_patch_deepseek_reasoning_passback,
|
||||
_patch_openai_compat_content,
|
||||
_patch_openrouter_strip_responses_reasoning,
|
||||
)
|
||||
@@ -41,7 +42,6 @@ _VOLCENGINE_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
_DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
_DASHSCOPE_CODE_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1"
|
||||
|
||||
_DEEPSEEK_BASE_URL = "https://api.deepseek.com"
|
||||
_MOONSHOT_BASE_URL = "https://api.moonshot.cn/v1"
|
||||
_KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/"
|
||||
|
||||
@@ -89,10 +89,19 @@ def _resolve_reasoning_effort(default: str) -> str:
|
||||
return os.environ.get("EVOSCIENTIST_REASONING_EFFORT", "").strip() or default
|
||||
|
||||
|
||||
def _is_deepseek_endpoint(base_url: str | None) -> bool:
|
||||
"""Return whether an OpenAI-compatible endpoint is DeepSeek's API."""
|
||||
if not base_url:
|
||||
return False
|
||||
try:
|
||||
return urlparse(base_url).hostname == "api.deepseek.com"
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
# Providers routed through the OpenAI provider with a custom base_url.
|
||||
# Maps provider name → (base_url or None, env var for API key).
|
||||
_OPENAI_ROUTED_PROVIDERS: dict[str, tuple[str | None, str]] = {
|
||||
"deepseek": (_DEEPSEEK_BASE_URL, "DEEPSEEK_API_KEY"),
|
||||
"moonshot": (_MOONSHOT_BASE_URL, "MOONSHOT_API_KEY"),
|
||||
"siliconflow": (_SILICONFLOW_BASE_URL, "SILICONFLOW_API_KEY"),
|
||||
"zhipu": (_ZHIPU_BASE_URL, "ZHIPU_API_KEY"),
|
||||
@@ -528,6 +537,11 @@ def get_chat_model(
|
||||
if api_key:
|
||||
kwargs["api_key"] = api_key
|
||||
|
||||
elif provider == "deepseek":
|
||||
api_key = os.environ.get("DEEPSEEK_API_KEY", "")
|
||||
if api_key:
|
||||
kwargs["api_key"] = api_key
|
||||
|
||||
# OpenAI-routed providers → route through OpenAI provider with base_url
|
||||
elif provider in _OPENAI_ROUTED_PROVIDERS:
|
||||
_original_provider = provider
|
||||
@@ -656,10 +670,23 @@ def get_chat_model(
|
||||
_apply_auto_config(provider, model_id, _is_third_party, kwargs, _original_provider)
|
||||
_apply_openrouter_anthropic_prompt_cache(provider, model_id, kwargs)
|
||||
|
||||
_uses_native_deepseek = provider == "deepseek" or (
|
||||
provider == "openai"
|
||||
and _original_provider == "custom-openai"
|
||||
and _is_deepseek_endpoint(kwargs.get("base_url"))
|
||||
)
|
||||
|
||||
# User-level override for the OpenAI Responses API vs Chat Completions.
|
||||
# When "false", force Chat Completions and drop reasoning (which triggers
|
||||
# the Responses API path in langchain-openai). Only applies to OpenAI.
|
||||
if provider == "openai":
|
||||
if _uses_native_deepseek:
|
||||
if kwargs.get("use_responses_api") is True:
|
||||
raise ValueError(
|
||||
"DeepSeek does not support the OpenAI Responses API. "
|
||||
"Remove use_responses_api=True."
|
||||
)
|
||||
kwargs.pop("use_responses_api", None)
|
||||
elif provider == "openai":
|
||||
_responses_api_setting = (
|
||||
os.environ.get("EVOSCIENTIST_USE_RESPONSES_API", "").strip().lower()
|
||||
)
|
||||
@@ -669,26 +696,26 @@ def get_chat_model(
|
||||
elif _responses_api_setting == "true":
|
||||
kwargs["use_responses_api"] = True
|
||||
|
||||
chat_model = init_chat_model(model=model_id, model_provider=provider, **kwargs)
|
||||
if _uses_native_deepseek:
|
||||
chat_model = EvoChatDeepSeek(model=model_id, **kwargs)
|
||||
else:
|
||||
chat_model = init_chat_model(model=model_id, model_provider=provider, **kwargs)
|
||||
|
||||
# Flatten list content to strings for strict OpenAI-compatible providers
|
||||
# (DeepSeek, SiliconFlow, OpenRouter, custom-openai, etc.) and
|
||||
# (SiliconFlow, OpenRouter, custom-openai, etc.) and
|
||||
# native OpenAI through a proxy, to avoid "sequence expected string" errors.
|
||||
# Moonshot and Kimi Coding support standard format, no patch needed.
|
||||
_no_patch_providers = {"moonshot", "kimi-coding"}
|
||||
if (
|
||||
_is_third_party or _is_openai_proxy
|
||||
) and _original_provider not in _no_patch_providers:
|
||||
(_is_third_party or _is_openai_proxy)
|
||||
and _original_provider not in _no_patch_providers
|
||||
and not _uses_native_deepseek
|
||||
):
|
||||
# Anthropic-routed providers accept media in tool results natively;
|
||||
# only OpenAI-compatible providers need tool-media hoisting.
|
||||
_hoist = _original_provider not in _ANTHROPIC_ROUTED_PROVIDERS
|
||||
_patch_openai_compat_content(chat_model, hoist_tool_media=_hoist)
|
||||
|
||||
# DeepSeek thinking mode requires reasoning_content passback in multi-turn
|
||||
# + tool_use scenarios.
|
||||
if _original_provider == "deepseek":
|
||||
_patch_deepseek_reasoning_passback(chat_model)
|
||||
|
||||
if _is_openai_proxy:
|
||||
_patch_ccproxy_system_to_developer(chat_model)
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Reusable behavior for OpenAI-compatible chat model integrations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Iterator, Mapping
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.callbacks import (
|
||||
AsyncCallbackManagerForLLMRun,
|
||||
CallbackManagerForLLMRun,
|
||||
)
|
||||
from langchain_core.messages import BaseMessage
|
||||
from langchain_core.outputs import ChatGenerationChunk, ChatResult
|
||||
|
||||
from .patches import _OpenAICompatContent
|
||||
|
||||
|
||||
class OpenAICompatContentMixin:
|
||||
"""Normalize message content before calling an OpenAI-compatible model."""
|
||||
|
||||
def _content_compat(self) -> _OpenAICompatContent:
|
||||
compat = self.__dict__.get("_evosci_content_compat")
|
||||
if not isinstance(compat, _OpenAICompatContent):
|
||||
profile = getattr(self, "profile", None)
|
||||
compat = _OpenAICompatContent(
|
||||
profile if isinstance(profile, Mapping) else None,
|
||||
hoist_tool_media=True,
|
||||
)
|
||||
self.__dict__["_evosci_content_compat"] = compat
|
||||
return compat
|
||||
|
||||
def _generate(
|
||||
self,
|
||||
messages: list[BaseMessage],
|
||||
stop: list[str] | None = None,
|
||||
run_manager: CallbackManagerForLLMRun | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ChatResult:
|
||||
return self._content_compat().invoke(
|
||||
super()._generate, # type: ignore[attr-defined]
|
||||
messages,
|
||||
stop=stop,
|
||||
run_manager=run_manager,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def _agenerate(
|
||||
self,
|
||||
messages: list[BaseMessage],
|
||||
stop: list[str] | None = None,
|
||||
run_manager: AsyncCallbackManagerForLLMRun | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ChatResult:
|
||||
return await self._content_compat().ainvoke(
|
||||
super()._agenerate, # type: ignore[attr-defined]
|
||||
messages,
|
||||
stop=stop,
|
||||
run_manager=run_manager,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _stream(
|
||||
self,
|
||||
messages: list[BaseMessage],
|
||||
stop: list[str] | None = None,
|
||||
run_manager: CallbackManagerForLLMRun | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Iterator[ChatGenerationChunk]:
|
||||
yield from self._content_compat().stream(
|
||||
super()._stream, # type: ignore[attr-defined]
|
||||
messages,
|
||||
stop=stop,
|
||||
run_manager=run_manager,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def _astream(
|
||||
self,
|
||||
messages: list[BaseMessage],
|
||||
stop: list[str] | None = None,
|
||||
run_manager: AsyncCallbackManagerForLLMRun | None = None,
|
||||
*,
|
||||
stream_usage: bool | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[ChatGenerationChunk]:
|
||||
async for chunk in self._content_compat().astream(
|
||||
super()._astream, # type: ignore[attr-defined]
|
||||
messages,
|
||||
stop=stop,
|
||||
run_manager=run_manager,
|
||||
stream_usage=stream_usage,
|
||||
**kwargs,
|
||||
):
|
||||
yield chunk
|
||||
+182
-235
@@ -11,9 +11,6 @@ Patches:
|
||||
- _patch_openai_capture_reasoning_content: capture provider
|
||||
reasoning_content into AIMessage.additional_kwargs (module-level,
|
||||
applied at import)
|
||||
- _patch_deepseek_reasoning_passback: re-inject reasoning_content into
|
||||
outgoing DeepSeek assistant messages for thinking-mode multi-turn /
|
||||
tool_use scenarios
|
||||
- _patch_openrouter_strip_responses_reasoning: drop OpenAI-Responses
|
||||
encrypted reasoning items (rs_* id) from outgoing OpenRouter messages
|
||||
(store=false → "Item with id rs_... not found")
|
||||
@@ -26,8 +23,11 @@ Utilities:
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import BaseMessage
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch: langchain-anthropic (>=1.3.4) calls .model_dump() on
|
||||
@@ -267,7 +267,9 @@ def _flatten_message_content(content: Any) -> str | list[Any] | Any:
|
||||
return "\n\n".join(parts) if parts else ""
|
||||
|
||||
|
||||
def _sanitize_messages(messages: list[Any], hoist_tool_media: bool = True) -> list[Any]:
|
||||
def _sanitize_messages(
|
||||
messages: list[BaseMessage], hoist_tool_media: bool = True
|
||||
) -> list[BaseMessage]:
|
||||
"""Flatten list content for OpenAI-compatible APIs, preserving media.
|
||||
|
||||
Text/reasoning content is flattened to a string; image blocks are
|
||||
@@ -282,7 +284,7 @@ def _sanitize_messages(messages: list[Any], hoist_tool_media: bool = True) -> li
|
||||
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
out: list[Any] = []
|
||||
out: list[BaseMessage] = []
|
||||
pending_media: list[Any] = [] # media hoisted out of a run of tool messages
|
||||
|
||||
def _flush() -> None:
|
||||
@@ -338,7 +340,7 @@ _FILE_ERROR_MARKERS = ("file input", "pdf input", "document input")
|
||||
_GENERIC_MEDIA_MARKERS = ("multimodal", "expected `text`")
|
||||
|
||||
|
||||
def _media_types_in(messages: list[Any]) -> set[str]:
|
||||
def _media_types_in(messages: list[BaseMessage]) -> set[str]:
|
||||
"""Set of preserved-media block types present across the messages."""
|
||||
found: set[str] = set()
|
||||
for msg in messages:
|
||||
@@ -375,7 +377,9 @@ def _is_http_400(exc: Exception) -> bool:
|
||||
return status == 400
|
||||
|
||||
|
||||
def _strip_media_types(messages: list[Any], types: set[str]) -> list[Any]:
|
||||
def _strip_media_types(
|
||||
messages: list[BaseMessage], types: set[str]
|
||||
) -> list[BaseMessage]:
|
||||
"""Replace blocks of the given types with a placeholder text block.
|
||||
|
||||
Each stripped block is replaced IN PLACE (consecutive ones collapse into one
|
||||
@@ -384,7 +388,7 @@ def _strip_media_types(messages: list[Any], types: set[str]) -> list[Any]:
|
||||
"""
|
||||
import copy
|
||||
|
||||
out: list[Any] = []
|
||||
out: list[BaseMessage] = []
|
||||
for msg in messages:
|
||||
content = getattr(msg, "content", None)
|
||||
if not isinstance(content, list) or not any(
|
||||
@@ -410,47 +414,174 @@ def _strip_media_types(messages: list[Any], types: set[str]) -> list[Any]:
|
||||
return out
|
||||
|
||||
|
||||
class _OpenAICompatContent:
|
||||
"""Apply OpenAI-compatible content normalization without owning a model."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
profile: Mapping[str, object] | None,
|
||||
hoist_tool_media: bool,
|
||||
) -> None:
|
||||
self.hoist_tool_media = hoist_tool_media
|
||||
self.blocked: set[str] = set()
|
||||
if profile is not None:
|
||||
if profile.get("image_inputs") is False:
|
||||
self.blocked |= _IMAGE_CONTENT_TYPES
|
||||
if profile.get("pdf_inputs") is False:
|
||||
self.blocked |= _FILE_CONTENT_TYPES
|
||||
|
||||
def _prepare(self, messages: list[BaseMessage]) -> list[BaseMessage]:
|
||||
prepared = (
|
||||
_strip_media_types(messages, self.blocked) if self.blocked else messages
|
||||
)
|
||||
return _sanitize_messages(prepared, self.hoist_tool_media)
|
||||
|
||||
def _stripped(
|
||||
self,
|
||||
messages: list[BaseMessage],
|
||||
suspects: set[str],
|
||||
) -> list[BaseMessage]:
|
||||
return _sanitize_messages(
|
||||
_strip_media_types(messages, self.blocked | suspects),
|
||||
self.hoist_tool_media,
|
||||
)
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
call: Callable[..., Any],
|
||||
messages: list[BaseMessage],
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> Any:
|
||||
suspects = _media_types_in(messages) - self.blocked
|
||||
prepared = self._prepare(messages)
|
||||
if not suspects:
|
||||
return call(prepared, *args, **kwargs)
|
||||
try:
|
||||
return call(prepared, *args, **kwargs)
|
||||
except Exception as exc:
|
||||
culprit = _media_error_types(exc) & suspects
|
||||
if not culprit and not _is_http_400(exc):
|
||||
raise
|
||||
try:
|
||||
result = call(self._stripped(messages, suspects), *args, **kwargs)
|
||||
except Exception:
|
||||
raise exc from None
|
||||
self.blocked.update(culprit)
|
||||
return result
|
||||
|
||||
async def ainvoke(
|
||||
self,
|
||||
call: Callable[..., Awaitable[Any]],
|
||||
messages: list[BaseMessage],
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> Any:
|
||||
suspects = _media_types_in(messages) - self.blocked
|
||||
prepared = self._prepare(messages)
|
||||
if not suspects:
|
||||
return await call(prepared, *args, **kwargs)
|
||||
try:
|
||||
return await call(prepared, *args, **kwargs)
|
||||
except Exception as exc:
|
||||
culprit = _media_error_types(exc) & suspects
|
||||
if not culprit and not _is_http_400(exc):
|
||||
raise
|
||||
try:
|
||||
result = await call(self._stripped(messages, suspects), *args, **kwargs)
|
||||
except Exception:
|
||||
# stripping didn't help — surface original, don't cache
|
||||
raise exc from None
|
||||
self.blocked.update(culprit)
|
||||
return result
|
||||
|
||||
def stream(
|
||||
self,
|
||||
call: Callable[..., Iterator[Any]],
|
||||
messages: list[BaseMessage],
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> Iterator[Any]:
|
||||
suspects = _media_types_in(messages) - self.blocked
|
||||
prepared = self._prepare(messages)
|
||||
if not suspects:
|
||||
yield from call(prepared, *args, **kwargs)
|
||||
return
|
||||
started = False
|
||||
try:
|
||||
for chunk in call(prepared, *args, **kwargs):
|
||||
started = True
|
||||
yield chunk
|
||||
return
|
||||
except Exception as exc:
|
||||
culprit = _media_error_types(exc) & suspects
|
||||
if started or (not culprit and not _is_http_400(exc)):
|
||||
raise
|
||||
media_exc = exc
|
||||
retry_started = False
|
||||
try:
|
||||
for chunk in call(self._stripped(messages, suspects), *args, **kwargs):
|
||||
if not retry_started:
|
||||
retry_started = True
|
||||
self.blocked.update(culprit)
|
||||
yield chunk
|
||||
except Exception:
|
||||
if retry_started:
|
||||
raise
|
||||
raise media_exc from None
|
||||
if not retry_started:
|
||||
raise media_exc from None
|
||||
|
||||
async def astream(
|
||||
self,
|
||||
call: Callable[..., AsyncIterator[Any]],
|
||||
messages: list[BaseMessage],
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> AsyncIterator[Any]:
|
||||
suspects = _media_types_in(messages) - self.blocked
|
||||
prepared = self._prepare(messages)
|
||||
if not suspects:
|
||||
async for chunk in call(prepared, *args, **kwargs):
|
||||
yield chunk
|
||||
return
|
||||
started = False
|
||||
try:
|
||||
async for chunk in call(prepared, *args, **kwargs):
|
||||
started = True
|
||||
yield chunk
|
||||
return
|
||||
except Exception as exc:
|
||||
culprit = _media_error_types(exc) & suspects
|
||||
if started or (not culprit and not _is_http_400(exc)):
|
||||
raise
|
||||
media_exc = exc
|
||||
retry_started = False
|
||||
try:
|
||||
async for chunk in call(
|
||||
self._stripped(messages, suspects), *args, **kwargs
|
||||
):
|
||||
if not retry_started:
|
||||
retry_started = True
|
||||
self.blocked.update(culprit)
|
||||
yield chunk
|
||||
except Exception:
|
||||
if retry_started:
|
||||
raise
|
||||
raise media_exc from None
|
||||
if not retry_started:
|
||||
raise media_exc from None
|
||||
|
||||
|
||||
def _patch_openai_compat_content(model: Any, hoist_tool_media: bool = True) -> None:
|
||||
"""Flatten list content to strings before OpenAI-compatible API calls.
|
||||
|
||||
Wraps ``_generate`` / ``_agenerate`` / ``_stream`` / ``_astream`` to prevent
|
||||
"invalid type: sequence, expected a string" errors from strict APIs like
|
||||
DeepSeek, while preserving image and file (PDF/document) blocks. Tracks a
|
||||
per-modality ``blocked`` set of media types the model rejects: when a request
|
||||
fails with a media-looking error it is retried with the offending modalities
|
||||
stripped to a placeholder, and those types are remembered ONLY after the
|
||||
stripped retry succeeds — so an unrelated 400 never permanently degrades the
|
||||
model, and a PDF rejection never disables images. The profile pre-seeds the
|
||||
set per modality (``image_inputs``/``pdf_inputs`` ``is False``).
|
||||
|
||||
Args:
|
||||
model: A LangChain chat model instance to patch in-place.
|
||||
hoist_tool_media: When True (OpenAI-compatible), media in a tool result
|
||||
is hoisted into a following HumanMessage (tool content must be a
|
||||
string). Anthropic-routed providers pass False (native support).
|
||||
"""
|
||||
"""Normalize content for OpenAI-compatible models lacking a native adapter."""
|
||||
import functools
|
||||
|
||||
from langchain_core.messages import BaseMessage
|
||||
|
||||
# Media block types this model rejects. Pre-seeded per modality from the
|
||||
# profile, then grown reactively — but only after a stripped retry succeeds.
|
||||
profile = getattr(model, "profile", None)
|
||||
blocked: set[str] = set()
|
||||
if isinstance(profile, dict):
|
||||
if profile.get("image_inputs") is False:
|
||||
blocked |= _IMAGE_CONTENT_TYPES
|
||||
if profile.get("pdf_inputs") is False:
|
||||
blocked |= _FILE_CONTENT_TYPES
|
||||
|
||||
def _prepare(messages: list[BaseMessage]) -> list[BaseMessage]:
|
||||
msgs = _strip_media_types(messages, blocked) if blocked else messages
|
||||
return _sanitize_messages(msgs, hoist_tool_media)
|
||||
|
||||
def _stripped(messages: list[BaseMessage], suspects: set[str]) -> list[BaseMessage]:
|
||||
return _sanitize_messages(
|
||||
_strip_media_types(messages, blocked | suspects), hoist_tool_media
|
||||
)
|
||||
compat = _OpenAICompatContent(
|
||||
profile if isinstance(profile, Mapping) else None,
|
||||
hoist_tool_media,
|
||||
)
|
||||
|
||||
orig_generate = getattr(model, "_generate", None)
|
||||
if orig_generate is None:
|
||||
@@ -460,23 +591,7 @@ def _patch_openai_compat_content(model: Any, hoist_tool_media: bool = True) -> N
|
||||
def _patched_generate(
|
||||
messages: list[BaseMessage], *args: Any, **kwargs: Any
|
||||
) -> Any:
|
||||
prepared = _prepare(messages)
|
||||
suspects = _media_types_in(messages) - blocked
|
||||
if not suspects:
|
||||
return orig_generate(prepared, *args, **kwargs)
|
||||
try:
|
||||
return orig_generate(prepared, *args, **kwargs)
|
||||
except Exception as exc:
|
||||
culprit = _media_error_types(exc) & suspects
|
||||
if not culprit and not _is_http_400(exc):
|
||||
raise
|
||||
try:
|
||||
result = orig_generate(_stripped(messages, suspects), *args, **kwargs)
|
||||
except Exception:
|
||||
# stripping didn't help — surface original, don't cache
|
||||
raise exc from None
|
||||
blocked.update(culprit) # cache only the marker-identified modality
|
||||
return result
|
||||
return compat.invoke(orig_generate, messages, *args, **kwargs)
|
||||
|
||||
model._generate = _patched_generate
|
||||
|
||||
@@ -487,24 +602,7 @@ def _patch_openai_compat_content(model: Any, hoist_tool_media: bool = True) -> N
|
||||
async def _patched_agenerate(
|
||||
messages: list[BaseMessage], *args: Any, **kwargs: Any
|
||||
) -> Any:
|
||||
prepared = _prepare(messages)
|
||||
suspects = _media_types_in(messages) - blocked
|
||||
if not suspects:
|
||||
return await orig_agenerate(prepared, *args, **kwargs)
|
||||
try:
|
||||
return await orig_agenerate(prepared, *args, **kwargs)
|
||||
except Exception as exc:
|
||||
culprit = _media_error_types(exc) & suspects
|
||||
if not culprit and not _is_http_400(exc):
|
||||
raise
|
||||
try:
|
||||
result = await orig_agenerate(
|
||||
_stripped(messages, suspects), *args, **kwargs
|
||||
)
|
||||
except Exception:
|
||||
raise exc from None
|
||||
blocked.update(culprit)
|
||||
return result
|
||||
return await compat.ainvoke(orig_agenerate, messages, *args, **kwargs)
|
||||
|
||||
model._agenerate = _patched_agenerate
|
||||
|
||||
@@ -517,38 +615,7 @@ def _patch_openai_compat_content(model: Any, hoist_tool_media: bool = True) -> N
|
||||
def _patched_stream(
|
||||
messages: list[BaseMessage], *args: Any, **kwargs: Any
|
||||
) -> Any:
|
||||
prepared = _prepare(messages)
|
||||
suspects = _media_types_in(messages) - blocked
|
||||
if not suspects:
|
||||
yield from orig_stream(prepared, *args, **kwargs)
|
||||
return
|
||||
started = False
|
||||
try:
|
||||
for chunk in orig_stream(prepared, *args, **kwargs):
|
||||
started = True
|
||||
yield chunk
|
||||
return
|
||||
except Exception as exc:
|
||||
culprit = _media_error_types(exc) & suspects
|
||||
if started or (not culprit and not _is_http_400(exc)):
|
||||
raise
|
||||
media_exc = exc
|
||||
media_culprit = culprit
|
||||
retry_started = False
|
||||
try:
|
||||
for chunk in orig_stream(
|
||||
_stripped(messages, suspects), *args, **kwargs
|
||||
):
|
||||
if not retry_started:
|
||||
retry_started = True
|
||||
blocked.update(media_culprit)
|
||||
yield chunk
|
||||
except Exception:
|
||||
if retry_started:
|
||||
raise
|
||||
raise media_exc from None
|
||||
if not retry_started:
|
||||
raise media_exc from None # stripped retry produced nothing
|
||||
yield from compat.stream(orig_stream, messages, *args, **kwargs)
|
||||
|
||||
model._stream = _patched_stream
|
||||
|
||||
@@ -559,39 +626,8 @@ def _patch_openai_compat_content(model: Any, hoist_tool_media: bool = True) -> N
|
||||
async def _patched_astream(
|
||||
messages: list[BaseMessage], *args: Any, **kwargs: Any
|
||||
) -> Any:
|
||||
prepared = _prepare(messages)
|
||||
suspects = _media_types_in(messages) - blocked
|
||||
if not suspects:
|
||||
async for chunk in orig_astream(prepared, *args, **kwargs):
|
||||
yield chunk
|
||||
return
|
||||
started = False
|
||||
try:
|
||||
async for chunk in orig_astream(prepared, *args, **kwargs):
|
||||
started = True
|
||||
yield chunk
|
||||
return
|
||||
except Exception as exc:
|
||||
culprit = _media_error_types(exc) & suspects
|
||||
if started or (not culprit and not _is_http_400(exc)):
|
||||
raise
|
||||
media_exc = exc
|
||||
media_culprit = culprit
|
||||
retry_started = False
|
||||
try:
|
||||
async for chunk in orig_astream(
|
||||
_stripped(messages, suspects), *args, **kwargs
|
||||
):
|
||||
if not retry_started:
|
||||
retry_started = True
|
||||
blocked.update(media_culprit)
|
||||
yield chunk
|
||||
except Exception:
|
||||
if retry_started:
|
||||
raise
|
||||
raise media_exc from None
|
||||
if not retry_started:
|
||||
raise media_exc from None # stripped retry produced nothing
|
||||
async for chunk in compat.astream(orig_astream, messages, *args, **kwargs):
|
||||
yield chunk
|
||||
|
||||
model._astream = _patched_astream
|
||||
|
||||
@@ -615,7 +651,7 @@ def _patch_ccproxy_system_to_developer(model: Any) -> None:
|
||||
import copy
|
||||
import functools
|
||||
|
||||
from langchain_core.messages import BaseMessage, SystemMessage
|
||||
from langchain_core.messages import SystemMessage
|
||||
|
||||
def _system_to_developer(messages: list[BaseMessage]) -> list[BaseMessage]:
|
||||
out: list[BaseMessage] = []
|
||||
@@ -843,95 +879,6 @@ def _patch_openrouter_strip_responses_reasoning() -> None:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch: DeepSeek thinking mode requires reasoning_content to be passed back
|
||||
# in all assistant messages for multi-turn + tool_use scenarios.
|
||||
# langchain-openai's _convert_message_to_dict drops this field, causing
|
||||
# HTTP 400 "The reasoning_content in the thinking mode must be passed back".
|
||||
# Mirrors langchain-ai/langchain PR #34516 (which patches langchain-deepseek;
|
||||
# we apply equivalent logic to a langchain-openai ChatOpenAI instance).
|
||||
# ---------------------------------------------------------------------------
|
||||
def _patch_deepseek_reasoning_passback(model: Any) -> None:
|
||||
"""Inject reasoning_content into outgoing payload assistant messages.
|
||||
|
||||
DeepSeek V4 thinking mode + tool_use requires every historical assistant
|
||||
message to carry its reasoning_content as a top-level field (sibling to
|
||||
content / tool_calls). Without this, multi-turn requests fail with 400.
|
||||
|
||||
For assistant messages where no reasoning_content was captured (e.g.
|
||||
history left over from another provider, from DeepSeek Flash, or from an
|
||||
older EvoSci version that ran before the capture patch landed), we
|
||||
inject an empty string. This satisfies DeepSeek's format requirement
|
||||
in thinking mode. Non-thinking DeepSeek endpoints are believed to
|
||||
accept the extra field without complaint based on observed behavior,
|
||||
but this has not been independently audited; if a future DeepSeek
|
||||
release rejects empty reasoning_content on non-thinking models, this
|
||||
fallback would need a per-call thinking-mode check instead of a blanket
|
||||
inject. The check is intentionally not gated on model name: this
|
||||
function is only mounted when provider == "deepseek" (see
|
||||
EvoScientist/llm/models.py), so all callers are DeepSeek endpoints.
|
||||
|
||||
Args:
|
||||
model: A langchain-openai ChatOpenAI instance configured for DeepSeek.
|
||||
"""
|
||||
import functools
|
||||
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
orig = getattr(model, "_get_request_payload", None)
|
||||
if orig is None:
|
||||
return
|
||||
|
||||
import logging as _logging
|
||||
|
||||
_logger = _logging.getLogger(__name__)
|
||||
|
||||
@functools.wraps(orig)
|
||||
def _patched(input_: Any, *, stop: Any = None, **kwargs: Any) -> dict:
|
||||
try:
|
||||
lc_messages = model._convert_input(input_).to_messages()
|
||||
except Exception:
|
||||
_logger.warning(
|
||||
"DeepSeek passback patch: _convert_input failed, "
|
||||
"falling back to unpatched payload (reasoning_content "
|
||||
"will not be injected)",
|
||||
exc_info=True,
|
||||
)
|
||||
return orig(input_, stop=stop, **kwargs)
|
||||
|
||||
ai_rcs: list[str | None] = [
|
||||
m.additional_kwargs.get("reasoning_content")
|
||||
for m in lc_messages
|
||||
if isinstance(m, AIMessage)
|
||||
]
|
||||
|
||||
payload = orig(input_, stop=stop, **kwargs)
|
||||
msgs = payload.get("messages")
|
||||
if not isinstance(msgs, list):
|
||||
return payload
|
||||
|
||||
ai_idx = 0
|
||||
for msg in msgs:
|
||||
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
||||
continue
|
||||
rc = ai_rcs[ai_idx] if ai_idx < len(ai_rcs) else None
|
||||
if rc:
|
||||
msg["reasoning_content"] = rc
|
||||
elif "reasoning_content" not in msg:
|
||||
# Empty-string fallback for ALL DeepSeek models (not just
|
||||
# reasoner). Required when history contains AI messages that
|
||||
# came from a different provider (Anthropic / OpenAI /
|
||||
# DeepSeek Flash) or from an older EvoSci that didn't capture
|
||||
# reasoning_content. Empirically tolerated by non-thinking
|
||||
# DeepSeek endpoints; see docstring for the audit caveat.
|
||||
msg["reasoning_content"] = ""
|
||||
ai_idx += 1
|
||||
|
||||
return payload
|
||||
|
||||
model._get_request_payload = _patched
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch: forward CLI's live (model, model_provider) into deepagents'
|
||||
# start_async_task / update_async_task tool calls so the deployed graph
|
||||
|
||||
@@ -90,6 +90,7 @@ _PROVIDER_EXC_MODULE_PREFIXES: tuple[str, ...] = (
|
||||
"google.api_core",
|
||||
"openrouter",
|
||||
"langchain_openai",
|
||||
"langchain_deepseek",
|
||||
"langchain_anthropic",
|
||||
"langchain_google_genai",
|
||||
"langchain_openrouter",
|
||||
|
||||
@@ -20,10 +20,18 @@ def disable_thinking(model: BaseChatModel) -> BaseChatModel:
|
||||
OpenAI reasoning can conflict. Strip these settings so structured
|
||||
output calls work reliably.
|
||||
|
||||
DeepSeek enables thinking server-side by default (no client field to
|
||||
clear), and its thinking mode rejects the forced ``tool_choice`` that
|
||||
``with_structured_output`` sends ("Thinking mode does not support this
|
||||
tool_choice"). For DeepSeek models the copy gets an explicit
|
||||
``extra_body["thinking"] = {"type": "disabled"}`` request field instead.
|
||||
|
||||
Uses ``model_copy()`` to produce a real new instance — ``bind()`` only
|
||||
wraps the model in a ``RunnableBinding`` whose kwargs do NOT override
|
||||
first-class Pydantic fields like ``thinking`` on ``ChatAnthropic``.
|
||||
"""
|
||||
from ..llm.errors import _provider_from_model
|
||||
|
||||
updates: dict[str, Any] = {}
|
||||
model_kwargs = getattr(model, "model_kwargs", {}) or {}
|
||||
|
||||
@@ -32,6 +40,17 @@ def disable_thinking(model: BaseChatModel) -> BaseChatModel:
|
||||
if getattr(model, "reasoning", None) or "reasoning" in model_kwargs:
|
||||
updates["reasoning"] = None
|
||||
|
||||
if _provider_from_model(model) == "deepseek":
|
||||
from ..llm.deepseek import (
|
||||
DEEPSEEK_THINKING_DISABLED,
|
||||
is_deepseek_thinking_disabled,
|
||||
)
|
||||
|
||||
extra_body = dict(getattr(model, "extra_body", None) or {})
|
||||
if not is_deepseek_thinking_disabled(extra_body):
|
||||
extra_body["thinking"] = dict(DEEPSEEK_THINKING_DISABLED)
|
||||
updates["extra_body"] = extra_body
|
||||
|
||||
if not updates:
|
||||
return model
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ dependencies = [
|
||||
"langchain>=1.3",
|
||||
"langchain-anthropic>=1.4",
|
||||
"langchain-openai>=1.2",
|
||||
"langchain-deepseek>=1.1",
|
||||
"langchain-nvidia-ai-endpoints>=1.2",
|
||||
"langchain-google-genai>=4.2",
|
||||
"langchain-ollama>=1.1",
|
||||
|
||||
@@ -126,6 +126,29 @@ class TestNormalize:
|
||||
req = _request(_google_model())
|
||||
assert _normalize(req, _make_exc()).provider == "google_genai"
|
||||
|
||||
def test_sdk_subclass_tagged_from_base_class(self):
|
||||
sdk_class = type(
|
||||
"ChatOpenAI",
|
||||
(),
|
||||
{"__module__": "langchain_openai.chat_models.base"},
|
||||
)
|
||||
evo_class = type(
|
||||
"EvoChatOpenAI",
|
||||
(sdk_class,),
|
||||
{"__module__": "EvoScientist.llm.test_models"},
|
||||
)
|
||||
model = evo_class()
|
||||
model.openai_api_base = None
|
||||
|
||||
assert _normalize(_request(model), _make_exc()).provider == "openai"
|
||||
|
||||
def test_deepseek_subclass_precedes_openai_base(self, monkeypatch):
|
||||
from EvoScientist.llm.deepseek import EvoChatDeepSeek
|
||||
|
||||
monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-test")
|
||||
req = _request(EvoChatDeepSeek(model="deepseek-v4-flash"))
|
||||
assert _normalize(req, _make_exc()).provider == "deepseek"
|
||||
|
||||
def test_unrecognized_model_class_returns_none(self):
|
||||
req = _request(_fake_model("some.other.pkg", "SomeModel"))
|
||||
assert _normalize(req, _make_exc()) is None
|
||||
|
||||
+124
-243
@@ -397,6 +397,17 @@ class TestThirdPartyRouting:
|
||||
# SiliconFlow should disable thinking
|
||||
assert call_kwargs["extra_body"]["enable_thinking"] is False
|
||||
|
||||
@patch("EvoScientist.llm.models.init_chat_model")
|
||||
def test_deepseek_uses_copy_safe_native_model(self, mock_init, monkeypatch):
|
||||
from EvoScientist.llm.deepseek import EvoChatDeepSeek
|
||||
|
||||
monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-test")
|
||||
|
||||
model = get_chat_model("deepseek-v4-flash", provider="deepseek")
|
||||
|
||||
mock_init.assert_not_called()
|
||||
assert isinstance(model, EvoChatDeepSeek)
|
||||
|
||||
@patch("EvoScientist.llm.models.init_chat_model")
|
||||
def test_openrouter_uses_native_provider(self, mock_init, monkeypatch):
|
||||
"""OpenRouter should use native 'openrouter' provider via init_chat_model."""
|
||||
@@ -1947,279 +1958,119 @@ class TestNoVisionFallback:
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test _patch_deepseek_reasoning_passback
|
||||
# Test DeepSeek model integration
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestPatchDeepseekReasoningPassback:
|
||||
"""Verify reasoning_content is injected into DeepSeek payload assistant messages.
|
||||
def test_deepseek_model_strips_unsupported_tool_media(monkeypatch):
|
||||
import json
|
||||
|
||||
This patch fixes the 400 error from DeepSeek V4 thinking mode in multi-turn
|
||||
+ tool_use scenarios. See langchain PR #34516 for upstream reference.
|
||||
"""
|
||||
import httpx
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
|
||||
def _make_model(self, model_name="deepseek-v4-pro", payload_messages=None):
|
||||
"""Create a mock ChatOpenAI-like model for the DeepSeek base URL."""
|
||||
from unittest.mock import MagicMock
|
||||
monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-test")
|
||||
captured = {}
|
||||
|
||||
if payload_messages is None:
|
||||
payload_messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hello"},
|
||||
{"role": "user", "content": "ok"},
|
||||
]
|
||||
|
||||
model = MagicMock()
|
||||
model.model_name = model_name
|
||||
|
||||
class _Wrapped:
|
||||
def __init__(self, msgs):
|
||||
self._msgs = msgs
|
||||
|
||||
def to_messages(self):
|
||||
return self._msgs
|
||||
|
||||
model._convert_input = lambda x: _Wrapped(x)
|
||||
model._get_request_payload = MagicMock(
|
||||
return_value={"messages": payload_messages}
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
captured.update(json.loads(request.content))
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "chatcmpl-1",
|
||||
"object": "chat.completion",
|
||||
"created": 1,
|
||||
"model": "deepseek-v4-flash",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"finish_reason": "stop",
|
||||
"message": {"role": "assistant", "content": "ok"},
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 1,
|
||||
"completion_tokens": 1,
|
||||
"total_tokens": 2,
|
||||
},
|
||||
},
|
||||
)
|
||||
return model
|
||||
|
||||
def test_injects_reasoning_content_from_additional_kwargs(self):
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from EvoScientist.llm.patches import _patch_deepseek_reasoning_passback
|
||||
|
||||
model = self._make_model()
|
||||
_patch_deepseek_reasoning_passback(model)
|
||||
|
||||
messages = [
|
||||
HumanMessage("hi"),
|
||||
AIMessage(
|
||||
content="hello",
|
||||
additional_kwargs={"reasoning_content": "let me think..."},
|
||||
),
|
||||
HumanMessage("ok"),
|
||||
]
|
||||
payload = model._get_request_payload(messages)
|
||||
|
||||
assert payload["messages"][1]["reasoning_content"] == "let me think..."
|
||||
|
||||
def test_empty_reasoning_for_reasoner_model(self):
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from EvoScientist.llm.patches import _patch_deepseek_reasoning_passback
|
||||
|
||||
model = self._make_model(model_name="deepseek-reasoner")
|
||||
_patch_deepseek_reasoning_passback(model)
|
||||
|
||||
messages = [
|
||||
HumanMessage("hi"),
|
||||
AIMessage(content="hello"), # no reasoning_content
|
||||
HumanMessage("ok"),
|
||||
]
|
||||
payload = model._get_request_payload(messages)
|
||||
|
||||
assert payload["messages"][1]["reasoning_content"] == ""
|
||||
|
||||
def test_empty_fallback_for_non_reasoner_without_rc(self):
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from EvoScientist.llm.patches import _patch_deepseek_reasoning_passback
|
||||
|
||||
model = self._make_model(model_name="deepseek-v4-pro")
|
||||
_patch_deepseek_reasoning_passback(model)
|
||||
|
||||
messages = [
|
||||
HumanMessage("hi"),
|
||||
AIMessage(content="hello"), # no reasoning_content
|
||||
HumanMessage("ok"),
|
||||
]
|
||||
payload = model._get_request_payload(messages)
|
||||
|
||||
# Empty-string fallback applies to ALL DeepSeek models (not just
|
||||
# reasoner) so cross-provider history doesn't trigger 400.
|
||||
assert payload["messages"][1]["reasoning_content"] == ""
|
||||
|
||||
def test_handles_multiple_ai_messages(self):
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from EvoScientist.llm.patches import _patch_deepseek_reasoning_passback
|
||||
|
||||
model = self._make_model(
|
||||
payload_messages=[
|
||||
{"role": "user", "content": "q1"},
|
||||
{"role": "assistant", "content": "a1"},
|
||||
{"role": "user", "content": "q2"},
|
||||
{"role": "assistant", "content": "a2"},
|
||||
{"role": "user", "content": "q3"},
|
||||
with httpx.Client(transport=httpx.MockTransport(respond)) as client:
|
||||
model = get_chat_model(
|
||||
"deepseek-v4-flash",
|
||||
provider="deepseek",
|
||||
http_client=client,
|
||||
)
|
||||
model.invoke(
|
||||
[
|
||||
HumanMessage("inspect the file"),
|
||||
AIMessage(
|
||||
"",
|
||||
tool_calls=[{"name": "read_file", "args": {}, "id": "call_1"}],
|
||||
),
|
||||
ToolMessage(
|
||||
content_blocks=[
|
||||
{"type": "image", "base64": "AAA", "mime_type": "image/png"}
|
||||
],
|
||||
tool_call_id="call_1",
|
||||
),
|
||||
]
|
||||
)
|
||||
_patch_deepseek_reasoning_passback(model)
|
||||
|
||||
assert captured["messages"][2]["content"] == (
|
||||
"[attachment omitted: this model does not support this input type]"
|
||||
)
|
||||
|
||||
|
||||
class TestDeepseekReasoningPassback:
|
||||
"""Verify reasoning_content is retained in serialized DeepSeek history."""
|
||||
|
||||
def test_request_payload_preserves_reasoning_for_tool_history(self, monkeypatch):
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
|
||||
from EvoScientist.llm.deepseek import EvoChatDeepSeek
|
||||
|
||||
monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-test")
|
||||
model = EvoChatDeepSeek(model="deepseek-v4-flash")
|
||||
messages = [
|
||||
HumanMessage("q1"),
|
||||
AIMessage(content="a1", additional_kwargs={"reasoning_content": "rc1"}),
|
||||
AIMessage(
|
||||
"",
|
||||
additional_kwargs={"reasoning_content": "rc1"},
|
||||
tool_calls=[{"name": "read_file", "args": {}, "id": "call_1"}],
|
||||
),
|
||||
ToolMessage("result", tool_call_id="call_1"),
|
||||
HumanMessage("q2"),
|
||||
AIMessage(content="a2", additional_kwargs={"reasoning_content": "rc2"}),
|
||||
AIMessage("a2"),
|
||||
HumanMessage("q3"),
|
||||
AIMessage("a3", additional_kwargs={"reasoning_content": "rc3"}),
|
||||
HumanMessage("q4"),
|
||||
]
|
||||
payload = model._get_request_payload(messages)
|
||||
|
||||
assert payload["messages"][1]["reasoning_content"] == "rc1"
|
||||
assert payload["messages"][3]["reasoning_content"] == "rc2"
|
||||
|
||||
def test_real_world_tool_use_flow(self):
|
||||
"""The real scenario this patch was built for: AI thinks → tool_call →
|
||||
ToolMessage → next turn must carry reasoning_content from prior AI msg.
|
||||
|
||||
This mirrors what happens in /tmp/verify_deepseek.py and what the user
|
||||
actually triggers via 'create file then read it' in EvoSci CLI.
|
||||
"""
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
|
||||
from EvoScientist.llm.patches import _patch_deepseek_reasoning_passback
|
||||
|
||||
# Mock payload that mirrors what langchain-openai produces:
|
||||
# user → assistant (with tool_calls) → tool_result → user (next turn)
|
||||
model = self._make_model(
|
||||
payload_messages=[
|
||||
{"role": "user", "content": "Read hello.txt"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "content": "file contents", "tool_call_id": "call_1"},
|
||||
{"role": "user", "content": "now what?"},
|
||||
]
|
||||
)
|
||||
_patch_deepseek_reasoning_passback(model)
|
||||
|
||||
messages = [
|
||||
HumanMessage("Read hello.txt"),
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={"reasoning_content": "I should call read_file"},
|
||||
tool_calls=[{"name": "read_file", "args": {}, "id": "call_1"}],
|
||||
),
|
||||
ToolMessage(content="file contents", tool_call_id="call_1"),
|
||||
HumanMessage("now what?"),
|
||||
]
|
||||
payload = model._get_request_payload(messages)
|
||||
|
||||
# The assistant message (index 1) must carry reasoning_content
|
||||
assistant_msg = payload["messages"][1]
|
||||
assert assistant_msg["role"] == "assistant"
|
||||
assert assistant_msg["reasoning_content"] == "I should call read_file"
|
||||
# tool_calls preserved
|
||||
assert "tool_calls" in assistant_msg
|
||||
# ToolMessage (index 2) untouched
|
||||
assert "tool_calls" in payload["messages"][1]
|
||||
assert "reasoning_content" not in payload["messages"][2]
|
||||
assert payload["messages"][4]["reasoning_content"] == ""
|
||||
assert payload["messages"][6]["reasoning_content"] == "rc3"
|
||||
|
||||
def test_mixed_ai_messages_with_and_without_rc(self):
|
||||
"""Some AIMessages have reasoning_content, some don't (e.g., legacy turns
|
||||
before patch was deployed). Each should be handled independently."""
|
||||
def test_thinking_disabled_copy_omits_reasoning_passback(self, monkeypatch):
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from EvoScientist.llm.patches import _patch_deepseek_reasoning_passback
|
||||
|
||||
model = self._make_model(
|
||||
model_name="deepseek-v4-pro",
|
||||
payload_messages=[
|
||||
{"role": "user", "content": "q1"},
|
||||
{"role": "assistant", "content": "a1"}, # no rc
|
||||
{"role": "user", "content": "q2"},
|
||||
{"role": "assistant", "content": "a2"}, # has rc
|
||||
{"role": "user", "content": "q3"},
|
||||
],
|
||||
)
|
||||
_patch_deepseek_reasoning_passback(model)
|
||||
from EvoScientist.llm.deepseek import EvoChatDeepSeek
|
||||
from EvoScientist.middleware.utils import disable_thinking
|
||||
|
||||
monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-test")
|
||||
model = disable_thinking(EvoChatDeepSeek(model="deepseek-v4-flash"))
|
||||
messages = [
|
||||
HumanMessage("q1"),
|
||||
AIMessage(content="a1"), # no reasoning_content
|
||||
AIMessage("a1", additional_kwargs={"reasoning_content": "rc1"}),
|
||||
HumanMessage("q2"),
|
||||
AIMessage(
|
||||
content="a2",
|
||||
additional_kwargs={"reasoning_content": "rc2"},
|
||||
),
|
||||
HumanMessage("q3"),
|
||||
]
|
||||
|
||||
payload = model._get_request_payload(messages)
|
||||
|
||||
# First AI msg: no rc → empty-string fallback (covers cross-model switch)
|
||||
assert payload["messages"][1]["reasoning_content"] == ""
|
||||
# Second AI msg: has rc → injected
|
||||
assert payload["messages"][3]["reasoning_content"] == "rc2"
|
||||
|
||||
def test_handles_responses_api_payload(self):
|
||||
"""Payload without 'messages' key (e.g. Responses API) should not crash."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
from EvoScientist.llm.patches import _patch_deepseek_reasoning_passback
|
||||
|
||||
model = MagicMock()
|
||||
model.model_name = "deepseek-v4-pro"
|
||||
|
||||
class _Wrapped:
|
||||
def __init__(self, msgs):
|
||||
self._msgs = msgs
|
||||
|
||||
def to_messages(self):
|
||||
return self._msgs
|
||||
|
||||
model._convert_input = lambda x: _Wrapped(x)
|
||||
# Simulate Responses API payload (no 'messages' key)
|
||||
model._get_request_payload = MagicMock(
|
||||
return_value={"input": [{"role": "user", "content": "hi"}]}
|
||||
)
|
||||
|
||||
_patch_deepseek_reasoning_passback(model)
|
||||
|
||||
# Should not raise, just return the payload as-is
|
||||
payload = model._get_request_payload([HumanMessage("hi")])
|
||||
assert "input" in payload
|
||||
assert "messages" not in payload
|
||||
|
||||
def test_cross_provider_switch_history(self):
|
||||
"""User chats with Anthropic/OpenAI then switches to DeepSeek V4 Pro.
|
||||
|
||||
Historical AI messages have no reasoning_content (the previous
|
||||
provider never produced it). The patch must inject an empty-string
|
||||
fallback so DeepSeek doesn't 400 on
|
||||
"reasoning_content must be passed back to the API".
|
||||
"""
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from EvoScientist.llm.patches import _patch_deepseek_reasoning_passback
|
||||
|
||||
model = self._make_model(
|
||||
model_name="deepseek-v4-pro",
|
||||
payload_messages=[
|
||||
{"role": "user", "content": "earlier question to anthropic"},
|
||||
{"role": "assistant", "content": "anthropic answer"},
|
||||
{"role": "user", "content": "now ask deepseek pro"},
|
||||
],
|
||||
)
|
||||
_patch_deepseek_reasoning_passback(model)
|
||||
|
||||
messages = [
|
||||
HumanMessage("earlier question to anthropic"),
|
||||
AIMessage(content="anthropic answer"), # no reasoning_content
|
||||
HumanMessage("now ask deepseek pro"),
|
||||
]
|
||||
payload = model._get_request_payload(messages)
|
||||
|
||||
assert payload["messages"][1]["reasoning_content"] == ""
|
||||
assert "reasoning_content" not in payload["messages"][1]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -2823,6 +2674,36 @@ class TestAutoConfig:
|
||||
assert "use_responses_api" not in call_kwargs
|
||||
assert call_kwargs["reasoning"] == {"effort": "high", "summary": "auto"}
|
||||
|
||||
def test_responses_api_env_ignored_for_host_routed_deepseek(self, monkeypatch):
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
from EvoScientist.llm.deepseek import EvoChatDeepSeek
|
||||
|
||||
monkeypatch.setenv("CUSTOM_OPENAI_API_KEY", "sk-test")
|
||||
monkeypatch.setenv("CUSTOM_OPENAI_BASE_URL", "https://api.deepseek.com")
|
||||
monkeypatch.setenv("EVOSCIENTIST_USE_RESPONSES_API", "true")
|
||||
|
||||
model = get_chat_model("deepseek-chat", provider="custom-openai")
|
||||
|
||||
assert isinstance(model, EvoChatDeepSeek)
|
||||
assert model.use_responses_api is not True
|
||||
assert "messages" in model._get_request_payload([HumanMessage("hi")])
|
||||
|
||||
@pytest.mark.parametrize("provider", ["deepseek", "custom-openai"])
|
||||
def test_deepseek_rejects_explicit_responses_api(self, monkeypatch, provider):
|
||||
if provider == "deepseek":
|
||||
monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-test")
|
||||
else:
|
||||
monkeypatch.setenv("CUSTOM_OPENAI_API_KEY", "sk-test")
|
||||
monkeypatch.setenv("CUSTOM_OPENAI_BASE_URL", "https://api.deepseek.com")
|
||||
|
||||
with pytest.raises(ValueError, match="does not support the OpenAI Responses"):
|
||||
get_chat_model(
|
||||
"deepseek-chat",
|
||||
provider=provider,
|
||||
use_responses_api=True,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("env_value", ["FALSE", " false ", "False"])
|
||||
@patch("EvoScientist.llm.models.init_chat_model")
|
||||
def test_use_responses_api_false_normalization(
|
||||
|
||||
@@ -476,3 +476,118 @@ def test_tool_selector_ordering(mock_config, mock_model, mock_ts):
|
||||
te_idx = type_names.index("ToolErrorHandlerMiddleware")
|
||||
mem_idx = type_names.index("EvoMemoryMiddleware")
|
||||
assert te_idx < ts_idx < mem_idx
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# disable_thinking: DeepSeek helper copies (issue #348)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _deepseek_model(monkeypatch, **kwargs):
|
||||
from EvoScientist.llm.deepseek import EvoChatDeepSeek
|
||||
|
||||
monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-test")
|
||||
return EvoChatDeepSeek(model="deepseek-v4-pro", **kwargs)
|
||||
|
||||
|
||||
def test_disable_thinking_deepseek_sets_request_field(monkeypatch):
|
||||
"""DeepSeek thinking is a server-side default; the helper copy must
|
||||
disable it in the request body, or the selector's forced tool_choice
|
||||
is rejected ("Thinking mode does not support this tool_choice")."""
|
||||
from EvoScientist.middleware.utils import disable_thinking
|
||||
|
||||
model = _deepseek_model(monkeypatch)
|
||||
safe = disable_thinking(model)
|
||||
|
||||
assert safe is not model
|
||||
assert safe.extra_body == {"thinking": {"type": "disabled"}}
|
||||
assert model.extra_body is None # original untouched
|
||||
assert type(safe) is type(model)
|
||||
|
||||
|
||||
def test_disable_thinking_deepseek_preserves_extra_body(monkeypatch):
|
||||
from EvoScientist.middleware.utils import disable_thinking
|
||||
|
||||
model = _deepseek_model(monkeypatch, extra_body={"custom": 1})
|
||||
safe = disable_thinking(model)
|
||||
|
||||
assert safe.extra_body == {"custom": 1, "thinking": {"type": "disabled"}}
|
||||
assert model.extra_body == {"custom": 1}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider", ["deepseek", "custom-openai"])
|
||||
async def test_deepseek_selector_uses_copy_settings(monkeypatch, provider):
|
||||
import json
|
||||
|
||||
import httpx
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
from EvoScientist.llm.models import get_chat_model
|
||||
|
||||
if provider == "deepseek":
|
||||
monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-test")
|
||||
else:
|
||||
monkeypatch.setenv("CUSTOM_OPENAI_API_KEY", "sk-test")
|
||||
monkeypatch.setenv("CUSTOM_OPENAI_BASE_URL", "https://api.deepseek.com")
|
||||
captured = {}
|
||||
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
captured.update(json.loads(request.content))
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "chatcmpl-1",
|
||||
"object": "chat.completion",
|
||||
"created": 1,
|
||||
"model": "deepseek-v4-flash",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"finish_reason": "tool_calls",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "ToolSelectionResponse",
|
||||
"arguments": json.dumps({"tools": ["tool_1"]}),
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 1,
|
||||
"completion_tokens": 1,
|
||||
"total_tokens": 2,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as client:
|
||||
model = get_chat_model(
|
||||
"deepseek-v4-flash",
|
||||
provider=provider,
|
||||
http_async_client=client,
|
||||
)
|
||||
selector = create_tool_selector_middleware(model=model, threshold=0)[0]
|
||||
request = ModelRequest(
|
||||
model=model,
|
||||
messages=[HumanMessage("pick a tool")],
|
||||
tools=[_tool(f"tool_{index}") for index in range(3)],
|
||||
)
|
||||
selected = []
|
||||
|
||||
async def handler(req):
|
||||
selected.extend(tool.name for tool in req.tools)
|
||||
|
||||
await selector.awrap_model_call(request, handler)
|
||||
|
||||
assert "response_format" not in captured
|
||||
assert captured["thinking"] == {"type": "disabled"}
|
||||
assert captured["tool_choice"]["function"]["name"] == "ToolSelectionResponse"
|
||||
assert selected == ["tool_1"]
|
||||
|
||||
@@ -944,7 +944,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "evoscientist"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "deepagents", extra = ["quickjs"] },
|
||||
@@ -952,6 +952,7 @@ dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "langchain" },
|
||||
{ name = "langchain-anthropic" },
|
||||
{ name = "langchain-deepseek" },
|
||||
{ name = "langchain-google-genai" },
|
||||
{ name = "langchain-mcp-adapters" },
|
||||
{ name = "langchain-nvidia-ai-endpoints" },
|
||||
@@ -1058,6 +1059,7 @@ requires-dist = [
|
||||
{ name = "httpx", specifier = ">=0.28" },
|
||||
{ name = "langchain", specifier = ">=1.3" },
|
||||
{ name = "langchain-anthropic", specifier = ">=1.4" },
|
||||
{ name = "langchain-deepseek", specifier = ">=1.1" },
|
||||
{ name = "langchain-google-genai", specifier = ">=4.2" },
|
||||
{ name = "langchain-mcp-adapters", specifier = ">=0.2" },
|
||||
{ name = "langchain-nvidia-ai-endpoints", specifier = ">=1.2" },
|
||||
@@ -2062,6 +2064,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/13/d6/bdf6f0481cc57ef300d6b1eb48cf1400c0409be715d6eb3cabadd1142a09/langchain_core-1.4.8-py3-none-any.whl", hash = "sha256:d84c28b05e3ba8d4271d0827aad5b592ccdaaf986e76768c23503f0a2045e8aa", size = 557416, upload-time = "2026-06-18T19:39:21.902Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-deepseek"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
{ name = "langchain-openai" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/f4/200792013d86406aa02aba8d1b536f8877a832372702189aef0c8c7a8329/langchain_deepseek-1.1.0.tar.gz", hash = "sha256:5ec8128cabae3d71366e2f297dea2153649be317a4be7f135cfcad1160523bf6", size = 138078, upload-time = "2026-06-03T19:07:11.887Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/1a/d18b7f985c35c635503a6cd426161933ffc7785f228d92447c63b026299a/langchain_deepseek-1.1.0-py3-none-any.whl", hash = "sha256:14813cb413a97a5cce95118da253cfd64dce50537b7381b7c5d0ecf11d2a7032", size = 10061, upload-time = "2026-06-03T19:07:11.04Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-google-genai"
|
||||
version = "4.2.5"
|
||||
|
||||
Reference in New Issue
Block a user