feat: enable reasoning for OpenRouter via extra_body to prevent multi… (#124)

* feat: enable reasoning for OpenRouter via extra_body to prevent multi-turn errors

* feat: implement OpenRouter native reasoning support and patch langchain-openrouter bug

* feat: add OpenRouter reasoning effort configuration and update related tests

* feat: add langchain-openrouter dependency for enhanced reasoning support

* fix: correct spacing in reasoning effort choice label

* feat: implement patch for OpenRouter reasoning details to prevent Pydantic errors

* feat: add patches for OpenRouter reasoning and content handling utilities

* feat: prevent multiple patches of OpenRouter reasoning details by using a global flag

* feat: update OpenRouter reasoning patch to ensure single application with global flag

* feat: refine OpenAI responses API handling to apply only for OpenAI provider

* feat: Enhance TUI interaction by updating todo widget positioning and skipping empty tool call chunks

* feat: Update tool selector threshold and adjust logging level for selector failures

* feat: Temporarily disable timestamp toast in tool call widget for UX review

* feat: Re-enable timestamp toast in tool call widget on click
This commit is contained in:
Xi Zhang
2026-04-03 12:04:34 +02:00
committed by GitHub
parent 897b444d27
commit 68f3ab2962
11 changed files with 328 additions and 172 deletions
+192
View File
@@ -0,0 +1,192 @@
"""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
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: 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