fix(llm): use native langchain-deepseek SDK (#349)

This commit is contained in:
dinos
2026-07-15 18:13:33 +02:00
committed by GitHub
parent 01845f4311
commit 05dfffbc73
12 changed files with 706 additions and 497 deletions
+80
View File
@@ -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)
+11 -5
View File
@@ -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
View File
@@ -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)
+94
View File
@@ -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
View File
@@ -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",
+19
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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(
+115
View File
@@ -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"]
Generated
+16 -1
View File
@@ -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"