Files
EvoScientist-Multi/EvoScientist/llm/patches.py
T
Xi Zhang 20c06d4897 feat(deepseek): implement reasoning_content passback for multi-turn s… (#190)
* feat(deepseek): implement reasoning_content passback for multi-turn scenarios

* fix(tests): ensure consistent import of EvoScientist.llm.patches in test cases

* fix(tests): streamline tool_calls formatting in TestPatchDeepseekReasoningPassback

* feat(patches): add reasoning_content capture and re-injection for DeepSeek assistant messages

* fix(deepseek): optimize reasoning_content extraction and assignment in passback

* fix(deepseek): refine reasoning_content handling in OpenAI capture patch
2026-04-26 14:53:08 +01:00

541 lines
20 KiB
Python

"""Monkey-patches and utilities for third-party LangChain provider quirks.
All patches follow the same pattern: wrap an existing method/function to
fix upstream bugs, applied at import time or on first use.
Patches:
- _patch_anthropic_proxy_compat: ccproxy dict→Pydantic model mismatch
- _patch_openrouter_reasoning_details: reasoning_details schema errors
- _patch_openai_compat_content: list content→string for strict APIs
- _patch_ccproxy_codex_compat: ccproxy model fixes + langchain None guard
- _patch_ccproxy_system_to_developer: system→developer role for ccproxy
- _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
Utilities:
- _is_ccproxy_codex: detect ccproxy Codex OAuth adapter
- _flatten_message_content: convert content blocks to plain string
"""
from __future__ import annotations
import os
from typing import Any
# ---------------------------------------------------------------------------
# Patch: langchain-anthropic (>=1.3.4) calls .model_dump() on
# context_management / container objects returned by the Anthropic SDK.
# Proxies like ccproxy may return plain dicts which lack that method.
# We wrap the class method to pre-convert dicts before the original runs.
# ---------------------------------------------------------------------------
def _patch_anthropic_proxy_compat() -> None:
try:
import types as _types
from langchain_anthropic.chat_models import ChatAnthropic as _CA
_orig = _CA._make_message_chunk_from_anthropic_event
def _safe(self: Any, event: Any, *args: Any, **kwargs: Any) -> Any:
for obj, attrs in [
(event, ("context_management",)),
(getattr(event, "delta", None), ("container",)),
]:
if obj is None:
continue
for attr in attrs:
val = getattr(obj, attr, None)
if isinstance(val, dict):
d = val.copy()
setattr(
obj,
attr,
_types.SimpleNamespace(model_dump=lambda d=d, **kw: d),
)
return _orig(self, event, *args, **kwargs)
_CA._make_message_chunk_from_anthropic_event = _safe
except Exception:
pass
_patch_anthropic_proxy_compat()
# ---------------------------------------------------------------------------
# Patch: ccproxy-api 0.2.7 Codex compatibility.
#
# 1) ResponseObject.output is required but upstream may omit it → 502.
# Fix: make output default to [].
# 2) CodexMessage.role only allows "user"/"assistant" → 400 on system msgs.
# Fix: widen to also accept "system" and "developer".
# 3) langchain-openai iterates response.output which can be None after the
# proxy strips it. Fix: guard in _construct_lc_result_from_responses_api.
# ---------------------------------------------------------------------------
def _patch_ccproxy_codex_compat() -> None:
"""Patch ccproxy-api models for Responses API compatibility."""
# 1) Make ResponseObject.output optional (default=[])
try:
import ccproxy.llms.models.openai as _oai_mod
_OrigResponse = _oai_mod.ResponseObject
from pydantic import Field as _PydanticField
class _PatchedResponseObject(_OrigResponse): # type: ignore[misc]
output: list = _PydanticField(default_factory=list) # type: ignore[assignment]
model_config = _OrigResponse.model_config.copy()
_PatchedResponseObject.__name__ = "ResponseObject"
_PatchedResponseObject.__qualname__ = "ResponseObject"
_oai_mod.ResponseObject = _PatchedResponseObject # type: ignore[misc]
# Also patch modules that import ResponseObject directly
for _mod_path in (
"ccproxy.llms.formatters.openai_to_openai.responses",
"ccproxy.llms.formatters.anthropic_to_openai.responses",
):
try:
import importlib
_mod = importlib.import_module(_mod_path)
if hasattr(_mod, "ResponseObject"):
_mod.ResponseObject = _PatchedResponseObject # type: ignore[attr-defined]
except Exception:
pass
except Exception:
pass
# 2) Widen CodexMessage.role to accept system/developer
try:
from typing import Annotated, Literal
import ccproxy.plugins.codex.models as _codex_mod
_OrigMessage = _codex_mod.CodexMessage
from pydantic import Field as _Field
class _PatchedCodexMessage(_OrigMessage): # type: ignore[misc]
role: Annotated[ # type: ignore[assignment]
Literal["user", "assistant", "system", "developer"],
_Field(description="Message role"),
]
_PatchedCodexMessage.__name__ = "CodexMessage"
_PatchedCodexMessage.__qualname__ = "CodexMessage"
_codex_mod.CodexMessage = _PatchedCodexMessage # type: ignore[misc]
except Exception:
pass
# 3) Fix StreamingBufferService returning response.completed event
# whose output is None/empty, instead of using accumulated outputs.
try:
from ccproxy.llms.streaming.accumulators import ResponsesAccumulator
_orig_get = ResponsesAccumulator.get_completed_response
def _patched_get(self: Any) -> dict | None:
result = _orig_get(self)
if result is not None:
output = result.get("output")
if output is None:
# output field lost — force rebuild from accumulated items
return None
return result
ResponsesAccumulator.get_completed_response = _patched_get # type: ignore[assignment]
except Exception:
pass
# 4) Guard langchain-openai against None output (final safety net)
try:
import langchain_openai.chat_models.base as _base
_orig_construct = _base._construct_lc_result_from_responses_api
def _safe(response: Any, *args: Any, **kwargs: Any) -> Any:
if response.output is None:
response.output = []
return _orig_construct(response, *args, **kwargs)
_base._construct_lc_result_from_responses_api = _safe
except Exception:
pass
_patch_ccproxy_codex_compat()
# ---------------------------------------------------------------------------
# Patch: langchain-openrouter v0.2.1 — _convert_message_to_dict() serializes
# reasoning_details back to the API, but streaming chunks use wrong field
# names per type (thinking→content, reasoning.summary→summary,
# reasoning.encrypted→data), causing Pydantic errors on multi-turn.
# Fix: wrap the function to drop reasoning_details from output.
# ---------------------------------------------------------------------------
_openrouter_patched = False
def _patch_openrouter_reasoning_details() -> None:
global _openrouter_patched
if _openrouter_patched:
return
try:
import langchain_openrouter.chat_models as _mod
_orig = _mod._convert_message_to_dict
def _patched(message: Any) -> Any:
result = _orig(message)
result.pop("reasoning_details", None)
return result
_mod._convert_message_to_dict = _patched
_openrouter_patched = True
except Exception:
pass
# ---------------------------------------------------------------------------
# Utility: detect ccproxy's Codex adapter (as opposed to generic localhost).
# ---------------------------------------------------------------------------
def _is_ccproxy_codex() -> bool:
"""Return True if the OpenAI endpoint is ccproxy's Codex adapter.
Checks for the ccproxy-specific markers set by ``setup_codex_env()``
in ``ccproxy_manager.py``: the sentinel API key and the ``/codex/v1``
path. Plain localhost endpoints (vLLM, Ollama, etc.) are not affected.
"""
base_url = os.environ.get("OPENAI_BASE_URL", "")
api_key = os.environ.get("OPENAI_API_KEY", "")
return (
("127.0.0.1" in base_url or "localhost" in base_url)
and api_key == "ccproxy-oauth"
and "/codex/" in base_url
)
# ---------------------------------------------------------------------------
# Utility + Patch: Flatten list content to strings for OpenAI-compatible APIs.
# DeepSeek, SiliconFlow, etc. reject assistant messages whose content is a
# list rather than a string.
# ---------------------------------------------------------------------------
_SKIP_CONTENT_TYPES = frozenset({"thinking", "reasoning", "reasoning_content"})
def _flatten_message_content(content: Any) -> str | Any:
"""Convert list-of-blocks content to a plain string.
Args:
content: Message content — either a string, a list of content blocks
(dicts with ``type`` and ``text`` keys), or another type.
Returns:
A plain string with text blocks joined by double newlines.
Thinking/reasoning blocks are skipped. Non-list input is
returned unchanged.
"""
if isinstance(content, str):
return content
if not isinstance(content, list):
return content
parts: list[str] = []
for block in content:
if isinstance(block, dict):
if block.get("type") in _SKIP_CONTENT_TYPES:
continue
text = block.get("text")
if text:
parts.append(text)
elif isinstance(block, str):
parts.append(block)
return "\n\n".join(parts) if parts else ""
def _patch_openai_compat_content(model: Any) -> None:
"""Flatten list content to strings before OpenAI-compatible API calls.
Wraps ``_generate`` / ``_agenerate`` to prevent "invalid type: sequence,
expected a string" errors from strict APIs like DeepSeek.
Args:
model: A LangChain chat model instance to patch in-place.
"""
import copy
import functools
from langchain_core.messages import BaseMessage
def _sanitize_messages(messages: list[BaseMessage]) -> list[BaseMessage]:
out: list[BaseMessage] = []
for msg in messages:
if isinstance(msg.content, list):
msg = copy.copy(msg)
msg.content = _flatten_message_content(msg.content)
out.append(msg)
return out
orig_generate = getattr(model, "_generate", None)
if orig_generate is None:
return
@functools.wraps(orig_generate)
def _patched_generate(
messages: list[BaseMessage], *args: Any, **kwargs: Any
) -> Any:
return orig_generate(_sanitize_messages(messages), *args, **kwargs)
model._generate = _patched_generate
orig_agenerate = getattr(model, "_agenerate", None)
if orig_agenerate is not None:
@functools.wraps(orig_agenerate)
async def _patched_agenerate(
messages: list[BaseMessage], *args: Any, **kwargs: Any
) -> Any:
return await orig_agenerate(_sanitize_messages(messages), *args, **kwargs)
model._agenerate = _patched_agenerate
# Also patch streaming paths — CLI/agent uses _stream/_astream, so without
# these the content flattening is bypassed during normal streaming calls.
orig_stream = getattr(model, "_stream", None)
if orig_stream is not None:
@functools.wraps(orig_stream)
def _patched_stream(
messages: list[BaseMessage], *args: Any, **kwargs: Any
) -> Any:
return orig_stream(_sanitize_messages(messages), *args, **kwargs)
model._stream = _patched_stream
orig_astream = getattr(model, "_astream", None)
if orig_astream is not None:
@functools.wraps(orig_astream)
async def _patched_astream(
messages: list[BaseMessage], *args: Any, **kwargs: Any
) -> Any:
async for chunk in orig_astream(
_sanitize_messages(messages), *args, **kwargs
):
yield chunk
model._astream = _patched_astream
# ---------------------------------------------------------------------------
# Patch: ccproxy Codex Responses API rejects "system" role messages.
# Convert SystemMessage to use "developer" role via langchain-openai's
# __openai_role__ mechanism.
# ---------------------------------------------------------------------------
def _patch_ccproxy_system_to_developer(model: Any) -> None:
"""Convert SystemMessage role from 'system' to 'developer' for ccproxy.
ccproxy's Responses API endpoint rejects system role messages with
400 "System messages are not allowed". LangChain's ``langchain_openai``
checks ``additional_kwargs["__openai_role__"]`` and uses that value as
the message role when serializing to the API.
Args:
model: A LangChain chat model instance to patch in-place.
"""
import copy
import functools
from langchain_core.messages import BaseMessage, SystemMessage
def _system_to_developer(messages: list[BaseMessage]) -> list[BaseMessage]:
out: list[BaseMessage] = []
for msg in messages:
if isinstance(msg, SystemMessage):
if msg.additional_kwargs.get("__openai_role__") != "developer":
msg = copy.copy(msg)
msg.additional_kwargs = {
**msg.additional_kwargs,
"__openai_role__": "developer",
}
out.append(msg)
return out
orig_generate = getattr(model, "_generate", None)
if orig_generate is None:
return
@functools.wraps(orig_generate)
def _patched_generate(
messages: list[BaseMessage], *args: Any, **kwargs: Any
) -> Any:
return orig_generate(_system_to_developer(messages), *args, **kwargs)
model._generate = _patched_generate
orig_agenerate = getattr(model, "_agenerate", None)
if orig_agenerate is not None:
@functools.wraps(orig_agenerate)
async def _patched_agenerate(
messages: list[BaseMessage], *args: Any, **kwargs: Any
) -> Any:
return await orig_agenerate(_system_to_developer(messages), *args, **kwargs)
model._agenerate = _patched_agenerate
orig_stream = getattr(model, "_stream", None)
if orig_stream is not None:
@functools.wraps(orig_stream)
def _patched_stream(
messages: list[BaseMessage], *args: Any, **kwargs: Any
) -> Any:
return orig_stream(_system_to_developer(messages), *args, **kwargs)
model._stream = _patched_stream
orig_astream = getattr(model, "_astream", None)
if orig_astream is not None:
@functools.wraps(orig_astream)
async def _patched_astream(
messages: list[BaseMessage], *args: Any, **kwargs: Any
) -> Any:
async for chunk in orig_astream(
_system_to_developer(messages), *args, **kwargs
):
yield chunk
model._astream = _patched_astream
# ---------------------------------------------------------------------------
# Patch (module-level): langchain-openai's _convert_dict_to_message and
# _convert_delta_to_message_chunk discard provider-specific fields like
# `reasoning_content`. We monkey-patch them to capture reasoning_content
# into AIMessage.additional_kwargs so downstream code (incl. our passback
# patch) can find it. Benign for non-DeepSeek providers — they just don't
# return this field, so the patch is a no-op for them.
# ---------------------------------------------------------------------------
_openai_capture_patched = False
def _patch_openai_capture_reasoning_content() -> None:
global _openai_capture_patched
if _openai_capture_patched:
return
try:
import langchain_openai.chat_models.base as _base
_orig_dict_to_msg = _base._convert_dict_to_message
_orig_delta_to_chunk = _base._convert_delta_to_message_chunk
def _patched_dict_to_msg(_dict, *args, **kwargs):
msg = _orig_dict_to_msg(_dict, *args, **kwargs)
rc = _dict.get("reasoning_content") if isinstance(_dict, dict) else None
if isinstance(rc, str) and rc and hasattr(msg, "additional_kwargs"):
msg.additional_kwargs["reasoning_content"] = rc
return msg
def _patched_delta_to_chunk(_dict, *args, **kwargs):
chunk = _orig_delta_to_chunk(_dict, *args, **kwargs)
rc = _dict.get("reasoning_content") if isinstance(_dict, dict) else None
if isinstance(rc, str) and rc and hasattr(chunk, "additional_kwargs"):
# Per-chunk: stash this delta's reasoning_content on the chunk.
# Cross-chunk accumulation is handled by AIMessageChunk.__add__
# via merge_dicts (string values in additional_kwargs concatenate).
chunk.additional_kwargs["reasoning_content"] = (
chunk.additional_kwargs.get("reasoning_content", "") + rc
)
return chunk
_base._convert_dict_to_message = _patched_dict_to_msg
_base._convert_delta_to_message_chunk = _patched_delta_to_chunk
_openai_capture_patched = True
except Exception:
pass
_patch_openai_capture_reasoning_content()
# ---------------------------------------------------------------------------
# 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 deepseek-reasoner models, also injects an empty reasoning_content
when none is present (DeepSeek API requires the field even if empty).
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
is_reasoner = "deepseek-reasoner" in str(getattr(model, "model_name", ""))
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 is_reasoner and "reasoning_content" not in msg:
msg["reasoning_content"] = ""
ai_idx += 1
return payload
model._get_request_payload = _patched