mirror of
https://github.com/Xe138/AI-Trader.git
synced 2026-04-02 09:37:23 -04:00
After extensive systematic debugging, identified and fixed LangChain bug where parse_tool_call() returns string args instead of dict. **Root Cause:** LangChain's parse_tool_call() has intermittent bug returning unparsed JSON string for 'args' field instead of dict object, violating AIMessage Pydantic schema. **Solution:** ToolCallArgsParsingWrapper provides two-layer fix: 1. Patches parse_tool_call() to detect string args and parse to dict 2. Normalizes non-standard tool_call formats to OpenAI standard **Implementation:** - Patches parse_tool_call in langchain_openai.chat_models.base namespace - Defensive approach: only acts when string args detected - Handles edge cases: invalid JSON, non-standard formats, invalid_tool_calls - Minimal performance impact: lightweight type checks - Thread-safe: patches apply at wrapper initialization **Testing:** - Confirmed fix working in production with DeepSeek Chat v3.1 - All tool calls now process successfully without validation errors - No impact on other AI providers (OpenAI, Anthropic, etc.) **Impact:** - Enables DeepSeek models via OpenRouter - Maintains backward compatibility - Future-proof against similar issues from other providers Closes systematic debugging investigation that spanned 6 alpha releases. Fixes: tool_calls.0.args validation error [type=dict_type, input_type=str]
122 lines
5.5 KiB
Python
122 lines
5.5 KiB
Python
"""
|
|
Chat model wrapper to fix tool_calls args parsing issues.
|
|
|
|
DeepSeek and other providers return tool_calls.args as JSON strings, which need
|
|
to be parsed to dicts before AIMessage construction.
|
|
"""
|
|
|
|
import json
|
|
from typing import Any, Optional, Dict
|
|
from functools import wraps
|
|
|
|
|
|
class ToolCallArgsParsingWrapper:
|
|
"""
|
|
Wrapper that adds diagnostic logging and fixes tool_calls args if needed.
|
|
"""
|
|
|
|
def __init__(self, model: Any, **kwargs):
|
|
"""
|
|
Initialize wrapper around a chat model.
|
|
|
|
Args:
|
|
model: The chat model to wrap
|
|
**kwargs: Additional parameters (ignored, for compatibility)
|
|
"""
|
|
self.wrapped_model = model
|
|
self._patch_model()
|
|
|
|
def _patch_model(self):
|
|
"""Monkey-patch the model's _create_chat_result to add diagnostics"""
|
|
if not hasattr(self.wrapped_model, '_create_chat_result'):
|
|
# Model doesn't have this method (e.g., MockChatModel), skip patching
|
|
return
|
|
|
|
# CRITICAL: Patch parse_tool_call in base.py's namespace (not in openai_tools module!)
|
|
from langchain_openai.chat_models import base as langchain_base
|
|
original_parse_tool_call = langchain_base.parse_tool_call
|
|
|
|
def patched_parse_tool_call(raw_tool_call, *, partial=False, strict=False, return_id=True):
|
|
"""Patched parse_tool_call to fix string args bug"""
|
|
result = original_parse_tool_call(raw_tool_call, partial=partial, strict=strict, return_id=return_id)
|
|
if result and isinstance(result.get('args'), str):
|
|
# FIX: parse_tool_call sometimes returns string args instead of dict
|
|
# This is a known LangChain bug - parse the string to dict
|
|
try:
|
|
result['args'] = json.loads(result['args'])
|
|
except (json.JSONDecodeError, TypeError):
|
|
# Leave as string if we can't parse it - will fail validation
|
|
# but at least we tried
|
|
pass
|
|
return result
|
|
|
|
# Replace in base.py's namespace (where _convert_dict_to_message uses it)
|
|
langchain_base.parse_tool_call = patched_parse_tool_call
|
|
|
|
original_create_chat_result = self.wrapped_model._create_chat_result
|
|
|
|
@wraps(original_create_chat_result)
|
|
def patched_create_chat_result(response: Any, generation_info: Optional[Dict] = None):
|
|
"""Patched version that normalizes non-standard tool_call formats"""
|
|
response_dict = response if isinstance(response, dict) else response.model_dump()
|
|
|
|
# Normalize tool_calls to OpenAI standard format if needed
|
|
if 'choices' in response_dict:
|
|
for choice in response_dict['choices']:
|
|
if 'message' not in choice:
|
|
continue
|
|
|
|
message = choice['message']
|
|
|
|
# Fix tool_calls: Convert non-standard {name, args, id} to {function: {name, arguments}, id}
|
|
if 'tool_calls' in message and message['tool_calls']:
|
|
for tool_call in message['tool_calls']:
|
|
# Check if this is non-standard format (has 'args' directly)
|
|
if 'args' in tool_call and 'function' not in tool_call:
|
|
# Convert to standard OpenAI format
|
|
args = tool_call['args']
|
|
tool_call['function'] = {
|
|
'name': tool_call.get('name', ''),
|
|
'arguments': args if isinstance(args, str) else json.dumps(args)
|
|
}
|
|
# Remove non-standard fields
|
|
if 'name' in tool_call:
|
|
del tool_call['name']
|
|
if 'args' in tool_call:
|
|
del tool_call['args']
|
|
|
|
# Fix invalid_tool_calls: Ensure args is JSON string (not dict)
|
|
if 'invalid_tool_calls' in message and message['invalid_tool_calls']:
|
|
for invalid_call in message['invalid_tool_calls']:
|
|
if 'args' in invalid_call and isinstance(invalid_call['args'], dict):
|
|
try:
|
|
invalid_call['args'] = json.dumps(invalid_call['args'])
|
|
except (TypeError, ValueError):
|
|
# Keep as-is if serialization fails
|
|
pass
|
|
|
|
# Call original method with normalized response
|
|
return original_create_chat_result(response_dict, generation_info)
|
|
|
|
# Replace the method
|
|
self.wrapped_model._create_chat_result = patched_create_chat_result
|
|
|
|
@property
|
|
def _llm_type(self) -> str:
|
|
"""Return identifier for this LLM type"""
|
|
if hasattr(self.wrapped_model, '_llm_type'):
|
|
return f"wrapped-{self.wrapped_model._llm_type}"
|
|
return "wrapped-chat-model"
|
|
|
|
def __getattr__(self, name: str):
|
|
"""Proxy all attributes/methods to the wrapped model"""
|
|
return getattr(self.wrapped_model, name)
|
|
|
|
def bind_tools(self, tools: Any, **kwargs):
|
|
"""Bind tools to the wrapped model"""
|
|
return self.wrapped_model.bind_tools(tools, **kwargs)
|
|
|
|
def bind(self, **kwargs):
|
|
"""Bind settings to the wrapped model"""
|
|
return self.wrapped_model.bind(**kwargs)
|