Compare commits

...

5 Commits

Author SHA1 Message Date
Devin AI
ed118abf56 Update tests to match new to_json implementation
Co-Authored-By: Joe Moura <joao@crewai.com>
2025-03-05 00:58:19 +00:00
Devin AI
0837e5e165 Fix type checking error in to_json method
Co-Authored-By: Joe Moura <joao@crewai.com>
2025-03-05 00:54:19 +00:00
Devin AI
66d7520694 Improve code quality based on PR feedback
Co-Authored-By: Joe Moura <joao@crewai.com>
2025-03-05 00:51:35 +00:00
Devin AI
be787ec62e Fix import sorting in test file
Co-Authored-By: Joe Moura <joao@crewai.com>
2025-03-05 00:43:23 +00:00
Devin AI
e0458132f5 Fix output_json parameter with custom_openai backends
Co-Authored-By: Joe Moura <joao@crewai.com>
2025-03-05 00:41:32 +00:00
2 changed files with 186 additions and 14 deletions

View File

@@ -1,6 +1,7 @@
import json
import logging
import re
from typing import Any, Optional, Type, Union, get_args, get_origin
from typing import Any, ClassVar, Optional, Type, Union, get_args, get_origin
from pydantic import BaseModel, ValidationError
@@ -17,8 +18,18 @@ class ConverterError(Exception):
self.message = message
class InstructorToolCallError(Exception):
"""Error raised when Instructor does not support multiple tool calls."""
def __init__(self, message: str, *args: object) -> None:
super().__init__(message, *args)
self.message = message
class Converter(OutputConverter):
"""Class that converts text into either pydantic or json."""
logger: ClassVar[logging.Logger] = logging.getLogger(__name__)
def to_pydantic(self, current_attempt=1) -> BaseModel:
"""Convert text to pydantic."""
@@ -68,29 +79,78 @@ class Converter(OutputConverter):
f"Failed to convert text into a Pydantic model due to error: {e}"
)
def to_json(self, current_attempt=1):
"""Convert text to json."""
def to_json(self, current_attempt: int = 1) -> dict:
"""
Convert text to JSON.
Args:
current_attempt: The current attempt number for retries.
Returns:
A dictionary containing the JSON data or raises ConverterError if conversion fails.
"""
try:
if self.llm.supports_function_calling():
return self._create_instructor().to_json()
try:
self.logger.debug("Using Instructor for JSON conversion")
return self._create_instructor().to_json()
except Exception as e:
# Check if this is the specific Instructor error for multiple tool calls
if "Instructor does not support multiple tool calls, use List[Model] instead" in str(e):
self.logger.warning(
"Instructor does not support multiple tool calls, falling back to simple JSON conversion"
)
return self._fallback_json_conversion()
raise e
else:
return json.dumps(
self.llm.call(
[
{"role": "system", "content": self.instructions},
{"role": "user", "content": self.text},
]
)
)
self.logger.debug("Using simple JSON conversion (no function calling support)")
return self._fallback_json_conversion()
except Exception as e:
if current_attempt < self.max_attempts:
self.logger.warning(f"JSON conversion failed, retrying (attempt {current_attempt})")
return self.to_json(current_attempt + 1)
return ConverterError(f"Failed to convert text into JSON, error: {e}.")
self.logger.error(f"JSON conversion failed after {self.max_attempts} attempts: {e}")
raise ConverterError(f"Failed to convert text into JSON, error: {e}.")
def _fallback_json_conversion(self) -> dict:
"""
Convert text to JSON using a simple approach without Instructor.
Returns:
A dictionary containing the JSON data or raises ConverterError if conversion fails.
"""
self.logger.debug("Using fallback JSON conversion method")
response = self.llm.call(
[
{"role": "system", "content": self.instructions},
{"role": "user", "content": self.text},
]
)
# Try to parse the response as JSON to ensure it's valid
try:
# If it's already a valid JSON string, parse it to a dict
if isinstance(response, str):
return json.loads(response)
# If it's already a dict, return it directly
if isinstance(response, dict):
return response
# Otherwise, try to convert it to a dict
return json.loads(json.dumps(response))
except json.JSONDecodeError as e:
self.logger.error(f"Invalid JSON in fallback conversion: {e}")
raise ConverterError(f"Failed to convert text into JSON, error: {e}.")
def _create_instructor(self):
"""Create an instructor."""
"""
Create an instructor instance for JSON conversion.
Returns:
An InternalInstructor instance.
"""
from crewai.utilities import InternalInstructor
self.logger.debug("Creating InternalInstructor instance")
inst = InternalInstructor(
llm=self.llm,
model=self.model,

View File

@@ -0,0 +1,112 @@
import json
from unittest.mock import Mock, patch
import pytest
from pydantic import BaseModel
from crewai.llm import LLM
from crewai.utilities.converter import Converter, ConverterError
class SimpleModel(BaseModel):
name: str
age: int
@pytest.fixture
def mock_llm_with_function_calling():
"""Create a mock LLM that supports function calling."""
llm = Mock(spec=LLM)
llm.supports_function_calling.return_value = True
llm.call.return_value = '{"name": "John", "age": 30}'
return llm
@pytest.fixture
def mock_instructor_with_error():
"""Create a mock Instructor that raises the specific error."""
mock_instructor = Mock()
mock_instructor.to_json.side_effect = Exception(
"Instructor does not support multiple tool calls, use List[Model] instead"
)
return mock_instructor
class TestCustomOpenAIJson:
def test_custom_openai_json_conversion_with_instructor_error(self, mock_llm_with_function_calling, mock_instructor_with_error):
"""Test that JSON conversion works with custom OpenAI backends when Instructor raises an error."""
# Create converter with mocked dependencies
converter = Converter(
llm=mock_llm_with_function_calling,
text="Convert this to JSON",
model=SimpleModel,
instructions="Convert to JSON",
)
# Mock the _create_instructor method to return our mocked instructor
with patch.object(converter, '_create_instructor', return_value=mock_instructor_with_error):
# Call to_json method
result = converter.to_json()
# Verify that the fallback mechanism was used
mock_llm_with_function_calling.call.assert_called_once()
# The result should be a dictionary
assert isinstance(result, dict)
assert result.get("name") == "John"
assert result.get("age") == 30
def test_custom_openai_json_conversion_without_error(self, mock_llm_with_function_calling):
"""Test that JSON conversion works normally when Instructor doesn't raise an error."""
# Mock Instructor that returns JSON without error
mock_instructor = Mock()
mock_instructor.to_json.return_value = {"name": "John", "age": 30}
# Create converter with mocked dependencies
converter = Converter(
llm=mock_llm_with_function_calling,
text="Convert this to JSON",
model=SimpleModel,
instructions="Convert to JSON",
)
# Mock the _create_instructor method to return our mocked instructor
with patch.object(converter, '_create_instructor', return_value=mock_instructor):
# Call to_json method
result = converter.to_json()
# Verify that the normal path was used (no fallback)
mock_llm_with_function_calling.call.assert_not_called()
# Verify the result matches the expected output
assert isinstance(result, dict)
assert result == {"name": "John", "age": 30}
def test_custom_openai_json_conversion_with_invalid_json(self, mock_llm_with_function_calling):
"""Test that JSON conversion handles invalid JSON gracefully."""
# Mock LLM to return invalid JSON
mock_llm_with_function_calling.call.return_value = 'invalid json'
# Mock Instructor that raises the specific error
mock_instructor = Mock()
mock_instructor.to_json.side_effect = Exception(
"Instructor does not support multiple tool calls, use List[Model] instead"
)
# Create converter with mocked dependencies
converter = Converter(
llm=mock_llm_with_function_calling,
text="Convert this to JSON",
model=SimpleModel,
instructions="Convert to JSON",
max_attempts=1, # Set max_attempts to 1 to avoid retries
)
# Mock the _create_instructor method to return our mocked instructor
with patch.object(converter, '_create_instructor', return_value=mock_instructor):
# Call to_json method and expect it to raise a ConverterError
with pytest.raises(ConverterError) as excinfo:
converter.to_json()
# Check the error message
assert "invalid json" in str(excinfo.value).lower() or "expecting value" in str(excinfo.value).lower()