fix: remove incorrect tool_calls conversion logic

Systematic debugging revealed the root cause of Pydantic validation errors:
- DeepSeek correctly returns tool_calls.arguments as JSON strings
- My wrapper was incorrectly converting strings to dicts
- This caused LangChain's parse_tool_call() to fail (json.loads(dict) error)
- Failure created invalid_tool_calls with dict args (should be string)
- Result: Pydantic validation error on invalid_tool_calls

Solution: Remove all conversion logic. DeepSeek format is already correct.

ToolCallArgsParsingWrapper now acts as a simple passthrough proxy.
Trading session completes successfully with no errors.

Fixes the systematic-debugging investigation that identified the
issue was in our fix attempt, not in the original API response.

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-05 21:18:54 -05:00
parent 0c6de5b74b
commit 0641ce554a
2 changed files with 19 additions and 82 deletions

View File

@@ -10,10 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.4.1] - 2025-11-05 ## [0.4.1] - 2025-11-05
### Fixed ### Fixed
- Fixed Pydantic validation errors for both `tool_calls` and `invalid_tool_calls` when using DeepSeek and other AI providers: - Resolved Pydantic validation errors when using DeepSeek Chat v3.1 through systematic debugging
- `tool_calls.args`: Converts JSON strings to dictionaries (for successful tool calls) - Root cause: Initial implementation incorrectly converted tool_calls arguments from strings to dictionaries, causing LangChain's `parse_tool_call()` to fail and create invalid_tool_calls with wrong format
- `invalid_tool_calls.args`: Converts dictionaries to JSON strings (for failed tool calls) - Solution: Removed unnecessary conversion logic - DeepSeek already returns arguments in correct format (JSON strings)
- Added `ToolCallArgsParsingWrapper` that monkey-patches ChatOpenAI's `_create_chat_result` method to normalize arguments before AIMessage construction. - `ToolCallArgsParsingWrapper` now acts as a simple passthrough proxy (kept for backward compatibility)
## [0.4.0] - 2025-11-05 ## [0.4.0] - 2025-11-05

View File

@@ -1,26 +1,24 @@
""" """
Chat model wrapper to fix tool_calls args parsing issues. Chat model wrapper - Passthrough wrapper for ChatOpenAI models.
Some AI providers (like DeepSeek) return tool_calls.args as JSON strings instead Originally created to fix DeepSeek tool_calls arg parsing issues, but investigation
of dictionaries, causing Pydantic validation errors. This wrapper monkey-patches revealed DeepSeek already returns the correct format (arguments as JSON strings).
the model to fix args before AIMessage construction.
This wrapper is now a simple passthrough that proxies all calls to the underlying model.
Kept for backward compatibility and potential future use.
""" """
import json from typing import Any
from typing import Any, List, Optional, Dict
from functools import wraps
from langchain_core.messages import AIMessage, BaseMessage
class ToolCallArgsParsingWrapper: class ToolCallArgsParsingWrapper:
""" """
Wrapper around ChatOpenAI that fixes tool_calls args parsing. Passthrough wrapper around ChatOpenAI models.
This fixes the Pydantic validation error: After systematic debugging, determined that DeepSeek returns tool_calls.arguments
"Input should be a valid dictionary [type=dict_type, input_value='...', input_type=str]" as JSON strings (correct format), so no parsing/conversion is needed.
Works by monkey-patching _create_chat_result to parse string args before This wrapper simply proxies all calls to the wrapped model.
AIMessage construction.
""" """
def __init__(self, model: Any, **kwargs): def __init__(self, model: Any, **kwargs):
@@ -28,63 +26,10 @@ class ToolCallArgsParsingWrapper:
Initialize wrapper around a chat model. Initialize wrapper around a chat model.
Args: Args:
model: The chat model to wrap (should be ChatOpenAI instance) model: The chat model to wrap
**kwargs: Additional parameters (ignored, for compatibility) **kwargs: Additional parameters (ignored, for compatibility)
""" """
self.wrapped_model = model self.wrapped_model = model
self._patch_model()
def _patch_model(self):
"""Monkey-patch the model's _create_chat_result to fix tool_calls args"""
if not hasattr(self.wrapped_model, '_create_chat_result'):
# Model doesn't have this method (e.g., MockChatModel), skip patching
return
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 fixes tool_calls args before AIMessage construction"""
# Fix tool_calls in the response dict before passing to original method
response_dict = response if isinstance(response, dict) else response.model_dump()
# DIAGNOSTIC: Log response structure
print(f"\n[DEBUG] Response keys: {response_dict.keys()}")
if 'choices' in response_dict:
print(f"[DEBUG] Number of choices: {len(response_dict['choices'])}")
for i, choice in enumerate(response_dict['choices']):
print(f"[DEBUG] Choice {i} keys: {choice.keys()}")
if 'message' in choice:
message = choice['message']
print(f"[DEBUG] Message keys: {message.keys()}")
# Check tool_calls structure
if 'tool_calls' in message and message['tool_calls']:
print(f"[DEBUG] Found {len(message['tool_calls'])} tool_calls")
for j, tc in enumerate(message['tool_calls']):
print(f"[DEBUG] tool_calls[{j}] keys: {tc.keys()}")
if 'function' in tc:
print(f"[DEBUG] tool_calls[{j}].function keys: {tc['function'].keys()}")
if 'arguments' in tc['function']:
args = tc['function']['arguments']
print(f"[DEBUG] tool_calls[{j}].function.arguments type: {type(args)}")
print(f"[DEBUG] tool_calls[{j}].function.arguments value: {repr(args)[:200]}")
if 'invalid_tool_calls' in message:
print(f"[DEBUG] Found invalid_tool_calls: {len(message['invalid_tool_calls'])} items")
for j, inv in enumerate(message['invalid_tool_calls']):
print(f"[DEBUG] invalid_tool_calls[{j}] keys: {inv.keys()}")
if 'args' in inv:
print(f"[DEBUG] invalid_tool_calls[{j}].args type: {type(inv['args'])}")
print(f"[DEBUG] invalid_tool_calls[{j}].args value: {inv['args']}")
# REMOVED: No conversion needed yet - gathering data first
# Call original method with unmodified response
return original_create_chat_result(response_dict, generation_info)
# Replace the method
self.wrapped_model._create_chat_result = patched_create_chat_result
@property @property
def _llm_type(self) -> str: def _llm_type(self) -> str:
@@ -94,21 +39,13 @@ class ToolCallArgsParsingWrapper:
return "wrapped-chat-model" return "wrapped-chat-model"
def __getattr__(self, name: str): def __getattr__(self, name: str):
"""Proxy all other attributes/methods to the wrapped model""" """Proxy all attributes/methods to the wrapped model"""
return getattr(self.wrapped_model, name) return getattr(self.wrapped_model, name)
def bind_tools(self, tools: Any, **kwargs): def bind_tools(self, tools: Any, **kwargs):
""" """Bind tools to the wrapped model"""
Bind tools to the wrapped model.
Since we patch the model in-place, we can just delegate to the wrapped model.
"""
return self.wrapped_model.bind_tools(tools, **kwargs) return self.wrapped_model.bind_tools(tools, **kwargs)
def bind(self, **kwargs): def bind(self, **kwargs):
""" """Bind settings to the wrapped model"""
Bind settings to the wrapped model.
Since we patch the model in-place, we can just delegate to the wrapped model.
"""
return self.wrapped_model.bind(**kwargs) return self.wrapped_model.bind(**kwargs)