From 05dfffbc7338a1c6b351ed09db6fccfac3b0e8bd Mon Sep 17 00:00:00 2001 From: dinos Date: Wed, 15 Jul 2026 18:13:33 +0200 Subject: [PATCH] fix(llm): use native langchain-deepseek SDK (#349) --- EvoScientist/llm/deepseek.py | 80 ++++ EvoScientist/llm/errors.py | 16 +- EvoScientist/llm/models.py | 53 ++- EvoScientist/llm/openai_compat.py | 94 ++++ EvoScientist/llm/patches.py | 417 ++++++++---------- .../middleware/error_normalization.py | 1 + EvoScientist/middleware/utils.py | 19 + pyproject.toml | 1 + tests/test_error_normalization_middleware.py | 23 + tests/test_llm.py | 367 ++++++--------- tests/test_tool_selector_middleware.py | 115 +++++ uv.lock | 17 +- 12 files changed, 706 insertions(+), 497 deletions(-) create mode 100644 EvoScientist/llm/deepseek.py create mode 100644 EvoScientist/llm/openai_compat.py diff --git a/EvoScientist/llm/deepseek.py b/EvoScientist/llm/deepseek.py new file mode 100644 index 0000000..a0f111f --- /dev/null +++ b/EvoScientist/llm/deepseek.py @@ -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) diff --git a/EvoScientist/llm/errors.py b/EvoScientist/llm/errors.py index 1140f12..d1f1114 100644 --- a/EvoScientist/llm/errors.py +++ b/EvoScientist/llm/errors.py @@ -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" ) diff --git a/EvoScientist/llm/models.py b/EvoScientist/llm/models.py index d21fd8b..960b078 100644 --- a/EvoScientist/llm/models.py +++ b/EvoScientist/llm/models.py @@ -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) diff --git a/EvoScientist/llm/openai_compat.py b/EvoScientist/llm/openai_compat.py new file mode 100644 index 0000000..bd4971f --- /dev/null +++ b/EvoScientist/llm/openai_compat.py @@ -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 diff --git a/EvoScientist/llm/patches.py b/EvoScientist/llm/patches.py index e376782..2473690 100644 --- a/EvoScientist/llm/patches.py +++ b/EvoScientist/llm/patches.py @@ -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 diff --git a/EvoScientist/middleware/error_normalization.py b/EvoScientist/middleware/error_normalization.py index 3ec816d..895646f 100644 --- a/EvoScientist/middleware/error_normalization.py +++ b/EvoScientist/middleware/error_normalization.py @@ -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", diff --git a/EvoScientist/middleware/utils.py b/EvoScientist/middleware/utils.py index bd61d41..4246303 100644 --- a/EvoScientist/middleware/utils.py +++ b/EvoScientist/middleware/utils.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index ccd4890..9219e70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/test_error_normalization_middleware.py b/tests/test_error_normalization_middleware.py index 62649bf..cbb27a2 100644 --- a/tests/test_error_normalization_middleware.py +++ b/tests/test_error_normalization_middleware.py @@ -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 diff --git a/tests/test_llm.py b/tests/test_llm.py index 5809748..039d990 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -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( diff --git a/tests/test_tool_selector_middleware.py b/tests/test_tool_selector_middleware.py index b2496ad..04ab193 100644 --- a/tests/test_tool_selector_middleware.py +++ b/tests/test_tool_selector_middleware.py @@ -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"] diff --git a/uv.lock b/uv.lock index e15f1d8..5254262 100644 --- a/uv.lock +++ b/uv.lock @@ -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"