mirror of
https://github.com/hwchase17/langchain.git
synced 2026-05-19 05:58:58 +00:00
Apply patch [skip ci]
This commit is contained in:
@@ -168,22 +168,22 @@ def _get_tool_calls_from_response(
|
||||
model_name: str = "",
|
||||
) -> list[ToolCall]:
|
||||
"""Get tool calls from ollama response.
|
||||
|
||||
|
||||
Args:
|
||||
response: The response from Ollama.
|
||||
model_name: Optional model name to determine if special parsing is needed.
|
||||
|
||||
|
||||
Returns:
|
||||
List of tool calls extracted from the response.
|
||||
"""
|
||||
tool_calls = []
|
||||
|
||||
|
||||
# Check if this is a gpt-oss model that might have different response format
|
||||
is_gpt_oss = model_name and _is_gpt_oss_model(model_name)
|
||||
|
||||
|
||||
if "message" in response:
|
||||
raw_tool_calls = response["message"].get("tool_calls")
|
||||
|
||||
|
||||
if raw_tool_calls:
|
||||
for tc in raw_tool_calls:
|
||||
try:
|
||||
@@ -211,7 +211,7 @@ def _get_tool_calls_from_response(
|
||||
# Standard OpenAI format for non-gpt-oss models
|
||||
name = tc["function"]["name"]
|
||||
args = _parse_arguments_from_tool_call(tc) or {}
|
||||
|
||||
|
||||
if name: # Only add if we have a valid name
|
||||
tool_calls.append(
|
||||
tool_call(
|
||||
@@ -227,7 +227,7 @@ def _get_tool_calls_from_response(
|
||||
f"Tool call data: {tc}"
|
||||
)
|
||||
continue
|
||||
|
||||
|
||||
return tool_calls
|
||||
|
||||
|
||||
@@ -261,10 +261,10 @@ def _is_pydantic_class(obj: Any) -> bool:
|
||||
|
||||
def _is_gpt_oss_model(model_name: str) -> bool:
|
||||
"""Check if the model is a gpt-oss variant that requires Harmony format.
|
||||
|
||||
|
||||
Args:
|
||||
model_name: The name of the model.
|
||||
|
||||
|
||||
Returns:
|
||||
True if the model is a gpt-oss variant, False otherwise.
|
||||
"""
|
||||
@@ -273,12 +273,12 @@ def _is_gpt_oss_model(model_name: str) -> bool:
|
||||
|
||||
def _convert_to_harmony_tool(tool: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Convert an OpenAI-format tool to Harmony format for gpt-oss models.
|
||||
|
||||
|
||||
The Harmony format differs from OpenAI format in how it structures tool
|
||||
definitions, particularly in parameter type specifications. The error
|
||||
"index $prop.Type 0: error calling index: reflect: slice index out of range"
|
||||
suggests that Ollama's gpt-oss template expects Type to be a string, not an array.
|
||||
|
||||
|
||||
Args:
|
||||
tool: Tool in OpenAI format with structure:
|
||||
{
|
||||
@@ -293,39 +293,36 @@ def _convert_to_harmony_tool(tool: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Returns:
|
||||
Tool in Harmony format compatible with gpt-oss models.
|
||||
"""
|
||||
if not tool or "function" not in tool:
|
||||
return tool
|
||||
|
||||
|
||||
# Extract the function definition
|
||||
function = tool.get("function", {})
|
||||
|
||||
|
||||
# Create Harmony-compatible format
|
||||
harmony_tool = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": function.get("name", ""),
|
||||
"description": function.get("description", ""),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Convert parameters to Harmony format
|
||||
if "parameters" in function:
|
||||
params = function["parameters"]
|
||||
harmony_params = {
|
||||
"type": params.get("type", "object"),
|
||||
"properties": {}
|
||||
}
|
||||
|
||||
harmony_params = {"type": params.get("type", "object"), "properties": {}}
|
||||
|
||||
# Process each property
|
||||
if "properties" in params:
|
||||
for prop_name, prop_def in params["properties"].items():
|
||||
# Ensure Type is a string, not an array
|
||||
harmony_prop = {}
|
||||
|
||||
|
||||
# Handle the type field specially for Harmony format
|
||||
if "type" in prop_def:
|
||||
# If type is an array (e.g., ["string", "null"]), take the first non-null type
|
||||
@@ -343,7 +340,7 @@ def _convert_to_harmony_tool(tool: dict[str, Any]) -> dict[str, Any]:
|
||||
else:
|
||||
# Default type if not specified
|
||||
harmony_prop["type"] = "string"
|
||||
|
||||
|
||||
# Copy other properties
|
||||
if "description" in prop_def:
|
||||
harmony_prop["description"] = prop_def["description"]
|
||||
@@ -351,15 +348,15 @@ def _convert_to_harmony_tool(tool: dict[str, Any]) -> dict[str, Any]:
|
||||
harmony_prop["enum"] = prop_def["enum"]
|
||||
if "default" in prop_def:
|
||||
harmony_prop["default"] = prop_def["default"]
|
||||
|
||||
|
||||
harmony_params["properties"][prop_name] = harmony_prop
|
||||
|
||||
|
||||
# Handle required fields
|
||||
if "required" in params:
|
||||
harmony_params["required"] = params["required"]
|
||||
|
||||
|
||||
harmony_tool["function"]["parameters"] = harmony_params
|
||||
|
||||
|
||||
return harmony_tool
|
||||
|
||||
|
||||
@@ -401,12 +398,12 @@ class ChatOllama(BaseChatModel):
|
||||
See full list of supported init args and their descriptions in the params section.
|
||||
|
||||
.. note::
|
||||
**GPT-OSS Model Support**: This integration includes special support for gpt-oss
|
||||
**GPT-OSS Model Support**: This integration includes special support for gpt-oss
|
||||
models (e.g., ``gpt-oss:20b``, ``gpt-oss:7b``) which use the Harmony response format.
|
||||
When using gpt-oss models with tools, the integration automatically converts tool
|
||||
definitions to the Harmony format, ensuring compatibility. Tool parameter types are
|
||||
properly formatted as strings rather than arrays to avoid template parsing errors.
|
||||
|
||||
|
||||
If you encounter issues with tool calling on gpt-oss models, ensure you're using
|
||||
the latest version of this integration which includes Harmony format support.
|
||||
|
||||
@@ -889,7 +886,7 @@ class ChatOllama(BaseChatModel):
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[Union[Mapping[str, Any], str]]:
|
||||
chat_params = self._chat_params(messages, stop, **kwargs)
|
||||
|
||||
|
||||
# Remove internal _harmony_format parameter before calling Ollama client
|
||||
chat_params.pop("_harmony_format", None)
|
||||
|
||||
@@ -906,7 +903,7 @@ class ChatOllama(BaseChatModel):
|
||||
**kwargs: Any,
|
||||
) -> Iterator[Union[Mapping[str, Any], str]]:
|
||||
chat_params = self._chat_params(messages, stop, **kwargs)
|
||||
|
||||
|
||||
# Remove internal _harmony_format parameter before calling Ollama client
|
||||
chat_params.pop("_harmony_format", None)
|
||||
|
||||
@@ -1219,7 +1216,7 @@ class ChatOllama(BaseChatModel):
|
||||
kwargs["_harmony_format"] = True
|
||||
else:
|
||||
formatted_tools = [convert_to_openai_tool(tool) for tool in tools]
|
||||
|
||||
|
||||
return super().bind(tools=formatted_tools, **kwargs)
|
||||
|
||||
def with_structured_output(
|
||||
@@ -1573,17 +1570,3 @@ class ChatOllama(BaseChatModel):
|
||||
)
|
||||
return RunnableMap(raw=llm) | parser_with_fallback
|
||||
return llm | output_parser
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
"""Test script to reproduce the gpt-oss:20b tool calling issue with ChatOllama."""
|
||||
|
||||
from langchain_core.tools import tool
|
||||
|
||||
from langchain_ollama import ChatOllama
|
||||
|
||||
|
||||
@tool
|
||||
def get_weather(location: str) -> str:
|
||||
"""Get the current weather for a location.
|
||||
|
||||
|
||||
Args:
|
||||
location: The city and state, e.g. San Francisco, CA
|
||||
|
||||
|
||||
Returns:
|
||||
A string describing the weather.
|
||||
"""
|
||||
@@ -21,10 +22,10 @@ def get_weather(location: str) -> str:
|
||||
@tool
|
||||
def calculate(expression: str) -> str:
|
||||
"""Calculate a mathematical expression.
|
||||
|
||||
|
||||
Args:
|
||||
expression: A mathematical expression to evaluate
|
||||
|
||||
|
||||
Returns:
|
||||
The result of the calculation as a string.
|
||||
"""
|
||||
@@ -39,15 +40,15 @@ def test_gpt_oss_tool_calling():
|
||||
"""Test tool calling with gpt-oss:20b model."""
|
||||
print("Testing ChatOllama with gpt-oss:20b model and tool calling...")
|
||||
print("-" * 60)
|
||||
|
||||
|
||||
# Initialize the model
|
||||
llm = ChatOllama(model="gpt-oss:20b", temperature=0)
|
||||
print(f"✓ Initialized ChatOllama with model: {llm.model}")
|
||||
|
||||
|
||||
# Define tools
|
||||
tools = [get_weather, calculate]
|
||||
print(f"✓ Defined {len(tools)} tools: {[t.name for t in tools]}")
|
||||
|
||||
|
||||
# Bind tools to the model
|
||||
try:
|
||||
llm_with_tools = llm.bind_tools(tools=tools)
|
||||
@@ -55,39 +56,40 @@ def test_gpt_oss_tool_calling():
|
||||
except Exception as e:
|
||||
print(f"✗ Error binding tools: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# Test queries that should trigger tool use
|
||||
test_queries = [
|
||||
"What's the weather like in San Francisco, CA?",
|
||||
"Calculate 42 * 17 for me",
|
||||
"What is 2 + 2?",
|
||||
]
|
||||
|
||||
|
||||
for i, query in enumerate(test_queries, 1):
|
||||
print(f"\nTest {i}: {query}")
|
||||
print("-" * 40)
|
||||
|
||||
|
||||
try:
|
||||
# Invoke the model with tools
|
||||
response = llm_with_tools.invoke(query)
|
||||
print(f"✓ Response received: {response}")
|
||||
|
||||
|
||||
# Check if tool calls were made
|
||||
if hasattr(response, 'tool_calls') and response.tool_calls:
|
||||
if hasattr(response, "tool_calls") and response.tool_calls:
|
||||
print(f"✓ Tool calls detected: {len(response.tool_calls)}")
|
||||
for tool_call in response.tool_calls:
|
||||
print(f" - Tool: {tool_call.get('name', 'unknown')}")
|
||||
print(f" Args: {tool_call.get('args', {})}")
|
||||
else:
|
||||
print("ℹ No tool calls in response")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error during invocation: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
|
||||
print("\nFull traceback:")
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Test completed successfully!")
|
||||
return True
|
||||
@@ -97,7 +99,7 @@ def test_without_tools():
|
||||
"""Test basic functionality without tools to ensure model works."""
|
||||
print("\nTesting basic ChatOllama without tools...")
|
||||
print("-" * 60)
|
||||
|
||||
|
||||
try:
|
||||
llm = ChatOllama(model="gpt-oss:20b", temperature=0)
|
||||
response = llm.invoke("Hello, how are you?")
|
||||
@@ -112,20 +114,20 @@ if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("ChatOllama gpt-oss:20b Tool Calling Test")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
# First test without tools to ensure basic functionality
|
||||
basic_works = test_without_tools()
|
||||
|
||||
|
||||
if basic_works:
|
||||
print("\n✓ Basic functionality confirmed. Testing tool calling...")
|
||||
# Now test with tools
|
||||
success = test_gpt_oss_tool_calling()
|
||||
|
||||
|
||||
if not success:
|
||||
print("\n⚠️ Tool calling test failed!")
|
||||
print("This confirms the issue described in the bug report.")
|
||||
print("\nExpected error:")
|
||||
print(" template: :108:130: executing \"\" at <index $prop.Type 0>:")
|
||||
print(' template: :108:130: executing "" at <index $prop.Type 0>:')
|
||||
print(" error calling index: reflect: slice index out of range")
|
||||
else:
|
||||
print("\n⚠️ Basic functionality test failed!")
|
||||
|
||||
@@ -18,7 +18,6 @@ from langchain_core.tools import tool
|
||||
|
||||
from langchain_ollama import ChatOllama
|
||||
|
||||
|
||||
# Skip all tests in this module if OLLAMA_BASE_URL is not set or Ollama is not available
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.environ.get("OLLAMA_BASE_URL") is None,
|
||||
@@ -29,7 +28,7 @@ pytestmark = pytest.mark.skipif(
|
||||
@tool
|
||||
def get_weather(location: str, unit: str = "celsius") -> str:
|
||||
"""Get the current weather for a location.
|
||||
|
||||
|
||||
Args:
|
||||
location: The city to get weather for.
|
||||
unit: Temperature unit (celsius or fahrenheit).
|
||||
@@ -41,7 +40,7 @@ def get_weather(location: str, unit: str = "celsius") -> str:
|
||||
@tool
|
||||
def search_web(query: str, max_results: int = 5) -> str:
|
||||
"""Search the web for information.
|
||||
|
||||
|
||||
Args:
|
||||
query: The search query.
|
||||
max_results: Maximum number of results to return.
|
||||
@@ -52,7 +51,7 @@ def search_web(query: str, max_results: int = 5) -> str:
|
||||
@tool
|
||||
def calculate(expression: str) -> str:
|
||||
"""Calculate a mathematical expression.
|
||||
|
||||
|
||||
Args:
|
||||
expression: The mathematical expression to evaluate.
|
||||
"""
|
||||
@@ -83,28 +82,27 @@ def check_model_available(model_name: str) -> bool:
|
||||
@pytest.mark.integration
|
||||
class TestGptOssToolCallingIntegration:
|
||||
"""Integration tests for gpt-oss model tool calling."""
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not check_ollama_available(),
|
||||
reason="Ollama is not available or not running"
|
||||
not check_ollama_available(), reason="Ollama is not available or not running"
|
||||
)
|
||||
@pytest.mark.skipif(
|
||||
not check_model_available("gpt-oss:20b"),
|
||||
reason="gpt-oss:20b model is not installed"
|
||||
reason="gpt-oss:20b model is not installed",
|
||||
)
|
||||
def test_single_tool_call(self) -> None:
|
||||
"""Test calling a single tool with gpt-oss model."""
|
||||
llm = ChatOllama(model="gpt-oss:20b", temperature=0)
|
||||
llm_with_tools = llm.bind_tools([get_weather])
|
||||
|
||||
|
||||
# Ask a question that should trigger tool use
|
||||
response = llm_with_tools.invoke(
|
||||
"What's the weather like in London? Please use the available tool."
|
||||
)
|
||||
|
||||
|
||||
# Check that the response is an AIMessage
|
||||
assert isinstance(response, AIMessage)
|
||||
|
||||
|
||||
# Check if tool calls were made (model might not always call tools)
|
||||
if response.tool_calls:
|
||||
assert len(response.tool_calls) > 0
|
||||
@@ -113,51 +111,49 @@ class TestGptOssToolCallingIntegration:
|
||||
assert "location" in tool_call["args"]
|
||||
# The model should identify London as the location
|
||||
assert "london" in tool_call["args"]["location"].lower()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not check_ollama_available(),
|
||||
reason="Ollama is not available or not running"
|
||||
not check_ollama_available(), reason="Ollama is not available or not running"
|
||||
)
|
||||
@pytest.mark.skipif(
|
||||
not check_model_available("gpt-oss:20b"),
|
||||
reason="gpt-oss:20b model is not installed"
|
||||
reason="gpt-oss:20b model is not installed",
|
||||
)
|
||||
def test_multiple_tools_binding(self) -> None:
|
||||
"""Test binding multiple tools to gpt-oss model."""
|
||||
llm = ChatOllama(model="gpt-oss:20b", temperature=0)
|
||||
llm_with_tools = llm.bind_tools([get_weather, search_web, calculate])
|
||||
|
||||
|
||||
# Test that tools are properly bound
|
||||
assert hasattr(llm_with_tools, "kwargs")
|
||||
assert "tools" in llm_with_tools.kwargs
|
||||
tools = llm_with_tools.kwargs["tools"]
|
||||
assert len(tools) == 3
|
||||
|
||||
|
||||
# Verify tool names
|
||||
tool_names = {tool["function"]["name"] for tool in tools}
|
||||
assert tool_names == {"get_weather", "search_web", "calculate"}
|
||||
|
||||
|
||||
# Test invocation with a query that might use search
|
||||
response = llm_with_tools.invoke(
|
||||
"Search for information about Python programming. Use the search tool."
|
||||
)
|
||||
|
||||
|
||||
assert isinstance(response, AIMessage)
|
||||
# The model may or may not call the tool depending on its decision
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not check_ollama_available(),
|
||||
reason="Ollama is not available or not running"
|
||||
not check_ollama_available(), reason="Ollama is not available or not running"
|
||||
)
|
||||
@pytest.mark.skipif(
|
||||
not check_model_available("gpt-oss:20b"),
|
||||
reason="gpt-oss:20b model is not installed"
|
||||
reason="gpt-oss:20b model is not installed",
|
||||
)
|
||||
def test_tool_call_with_conversation(self) -> None:
|
||||
"""Test tool calling within a conversation context."""
|
||||
llm = ChatOllama(model="gpt-oss:20b", temperature=0)
|
||||
llm_with_tools = llm.bind_tools([get_weather, calculate])
|
||||
|
||||
|
||||
# Create a conversation
|
||||
messages = [
|
||||
HumanMessage(content="Hi, I need help with two things."),
|
||||
@@ -167,9 +163,9 @@ class TestGptOssToolCallingIntegration:
|
||||
"Please use the available tools for both tasks."
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
response = llm_with_tools.invoke(messages)
|
||||
|
||||
|
||||
assert isinstance(response, AIMessage)
|
||||
# Check if the model made tool calls
|
||||
if response.tool_calls:
|
||||
@@ -179,59 +175,57 @@ class TestGptOssToolCallingIntegration:
|
||||
assert len(tool_names) > 0
|
||||
# The tools called should be from our available tools
|
||||
assert tool_names.issubset({"get_weather", "calculate"})
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not check_ollama_available(),
|
||||
reason="Ollama is not available or not running"
|
||||
not check_ollama_available(), reason="Ollama is not available or not running"
|
||||
)
|
||||
@pytest.mark.skipif(
|
||||
not check_model_available("gpt-oss:20b"),
|
||||
reason="gpt-oss:20b model is not installed"
|
||||
reason="gpt-oss:20b model is not installed",
|
||||
)
|
||||
def test_streaming_with_tools(self) -> None:
|
||||
"""Test streaming responses with tool calls."""
|
||||
llm = ChatOllama(model="gpt-oss:20b", temperature=0)
|
||||
llm_with_tools = llm.bind_tools([get_weather])
|
||||
|
||||
|
||||
# Stream a response
|
||||
chunks = []
|
||||
for chunk in llm_with_tools.stream(
|
||||
"What's the weather in Tokyo? Use the weather tool."
|
||||
):
|
||||
chunks.append(chunk)
|
||||
|
||||
|
||||
# Should have received chunks
|
||||
assert len(chunks) > 0
|
||||
|
||||
|
||||
# Combine chunks to get the full response
|
||||
final_message = chunks[0]
|
||||
for chunk in chunks[1:]:
|
||||
final_message += chunk
|
||||
|
||||
|
||||
# Check if tool calls were made in the final combined message
|
||||
if hasattr(final_message, "tool_calls") and final_message.tool_calls:
|
||||
tool_call = final_message.tool_calls[0]
|
||||
assert tool_call["name"] == "get_weather"
|
||||
assert "location" in tool_call["args"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not check_ollama_available(),
|
||||
reason="Ollama is not available or not running"
|
||||
not check_ollama_available(), reason="Ollama is not available or not running"
|
||||
)
|
||||
@pytest.mark.skipif(
|
||||
not check_model_available("gpt-oss:20b"),
|
||||
reason="gpt-oss:20b model is not installed"
|
||||
reason="gpt-oss:20b model is not installed",
|
||||
)
|
||||
async def test_async_tool_calling(self) -> None:
|
||||
"""Test asynchronous tool calling with gpt-oss model."""
|
||||
llm = ChatOllama(model="gpt-oss:20b", temperature=0)
|
||||
llm_with_tools = llm.bind_tools([calculate])
|
||||
|
||||
|
||||
# Test async invocation
|
||||
response = await llm_with_tools.ainvoke(
|
||||
"Calculate 42 times 10. Please use the calculate tool."
|
||||
)
|
||||
|
||||
|
||||
assert isinstance(response, AIMessage)
|
||||
# Check if tool was called
|
||||
if response.tool_calls:
|
||||
@@ -244,10 +238,9 @@ class TestGptOssToolCallingIntegration:
|
||||
@pytest.mark.integration
|
||||
class TestGptOssModelCompatibility:
|
||||
"""Test compatibility of different gpt-oss model variants."""
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not check_ollama_available(),
|
||||
reason="Ollama is not available or not running"
|
||||
not check_ollama_available(), reason="Ollama is not available or not running"
|
||||
)
|
||||
def test_gpt_oss_variants(self) -> None:
|
||||
"""Test that different gpt-oss model variants are detected correctly."""
|
||||
@@ -258,48 +251,47 @@ class TestGptOssModelCompatibility:
|
||||
"gpt-oss:20b",
|
||||
"gpt-oss:7b",
|
||||
]
|
||||
|
||||
|
||||
for model_name in model_variants:
|
||||
if check_model_available(model_name):
|
||||
llm = ChatOllama(model=model_name)
|
||||
llm_with_tools = llm.bind_tools([get_weather])
|
||||
|
||||
|
||||
# Verify tools are in Harmony format
|
||||
tools = llm_with_tools.kwargs["tools"]
|
||||
assert len(tools) == 1
|
||||
tool = tools[0]
|
||||
assert tool["type"] == "function"
|
||||
assert "function" in tool
|
||||
|
||||
|
||||
# Check parameter types are strings
|
||||
props = tool["function"]["parameters"]["properties"]
|
||||
for prop in props.values():
|
||||
if "type" in prop:
|
||||
assert isinstance(prop["type"], str)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not check_ollama_available(),
|
||||
reason="Ollama is not available or not running"
|
||||
not check_ollama_available(), reason="Ollama is not available or not running"
|
||||
)
|
||||
def test_non_gpt_oss_models_unchanged(self) -> None:
|
||||
"""Test that non-gpt-oss models still work with standard format."""
|
||||
# Test with a non-gpt-oss model if available
|
||||
non_gpt_models = ["llama2", "mistral", "codellama"]
|
||||
|
||||
|
||||
for model_name in non_gpt_models:
|
||||
if check_model_available(model_name):
|
||||
llm = ChatOllama(model=model_name)
|
||||
llm_with_tools = llm.bind_tools([get_weather])
|
||||
|
||||
|
||||
# Tools should still be bound
|
||||
tools = llm_with_tools.kwargs["tools"]
|
||||
assert len(tools) == 1
|
||||
|
||||
|
||||
# Should use standard OpenAI format
|
||||
tool = tools[0]
|
||||
assert tool["type"] == "function"
|
||||
assert "function" in tool
|
||||
|
||||
|
||||
# The format should be compatible with standard Ollama models
|
||||
break # Test with at least one non-gpt-oss model
|
||||
|
||||
@@ -307,19 +299,20 @@ class TestGptOssModelCompatibility:
|
||||
@pytest.mark.integration
|
||||
class TestGptOssErrorHandling:
|
||||
"""Test error handling for gpt-oss models with tools."""
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not check_ollama_available(),
|
||||
reason="Ollama is not available or not running"
|
||||
not check_ollama_available(), reason="Ollama is not available or not running"
|
||||
)
|
||||
@pytest.mark.skipif(
|
||||
not check_model_available("gpt-oss:20b"),
|
||||
reason="gpt-oss:20b model is not installed"
|
||||
reason="gpt-oss:20b model is not installed",
|
||||
)
|
||||
def test_malformed_tool_response_handling(self) -> None:
|
||||
"""Test that malformed tool responses are handled gracefully."""
|
||||
llm = ChatOllama(model="gpt-oss:20b", temperature=1.5) # High temp for randomness
|
||||
|
||||
llm = ChatOllama(
|
||||
model="gpt-oss:20b", temperature=1.5
|
||||
) # High temp for randomness
|
||||
|
||||
# Create a tool that might cause parsing issues
|
||||
@tool
|
||||
def complex_tool(
|
||||
@@ -327,43 +320,40 @@ class TestGptOssErrorHandling:
|
||||
nested: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""A complex tool with nested parameters.
|
||||
|
||||
|
||||
Args:
|
||||
data: Complex data structure.
|
||||
nested: Optional nested data.
|
||||
"""
|
||||
return "processed"
|
||||
|
||||
|
||||
llm_with_tools = llm.bind_tools([complex_tool])
|
||||
|
||||
|
||||
# This should not raise an error even if the model returns malformed tool calls
|
||||
try:
|
||||
response = llm_with_tools.invoke(
|
||||
"Use the complex tool with some data."
|
||||
)
|
||||
response = llm_with_tools.invoke("Use the complex tool with some data.")
|
||||
assert isinstance(response, AIMessage)
|
||||
except Exception as e:
|
||||
# The error should be handled gracefully
|
||||
pytest.fail(f"Tool calling raised an unexpected error: {e}")
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not check_ollama_available(),
|
||||
reason="Ollama is not available or not running"
|
||||
not check_ollama_available(), reason="Ollama is not available or not running"
|
||||
)
|
||||
@pytest.mark.skipif(
|
||||
not check_model_available("gpt-oss:20b"),
|
||||
reason="gpt-oss:20b model is not installed"
|
||||
reason="gpt-oss:20b model is not installed",
|
||||
)
|
||||
def test_empty_tool_list(self) -> None:
|
||||
"""Test that binding an empty tool list works correctly."""
|
||||
llm = ChatOllama(model="gpt-oss:20b")
|
||||
|
||||
|
||||
# Binding empty tool list should work
|
||||
llm_with_no_tools = llm.bind_tools([])
|
||||
|
||||
|
||||
# Should still be able to invoke
|
||||
response = llm_with_no_tools.invoke("Hello, how are you?")
|
||||
assert isinstance(response, AIMessage)
|
||||
|
||||
|
||||
# Should have no tool calls
|
||||
assert not response.tool_calls or len(response.tool_calls) == 0
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"""Unit tests for gpt-oss model support in ChatOllama."""
|
||||
|
||||
from typing import Any, Dict
|
||||
from unittest.mock import MagicMock, patch
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langchain_core.tools import tool
|
||||
|
||||
from langchain_ollama.chat_models import ChatOllama
|
||||
@@ -13,7 +11,7 @@ from langchain_ollama.chat_models import ChatOllama
|
||||
@tool
|
||||
def get_weather(location: str, unit: str = "celsius") -> str:
|
||||
"""Get the weather for a location.
|
||||
|
||||
|
||||
Args:
|
||||
location: The city to get weather for.
|
||||
unit: Temperature unit (celsius or fahrenheit).
|
||||
@@ -23,17 +21,17 @@ def get_weather(location: str, unit: str = "celsius") -> str:
|
||||
|
||||
class TestGptOssModelDetection:
|
||||
"""Test detection of gpt-oss models."""
|
||||
|
||||
|
||||
def test_detects_gpt_oss_model(self) -> None:
|
||||
"""Test that gpt-oss models are correctly detected."""
|
||||
from langchain_ollama.chat_models import _is_gpt_oss_model
|
||||
|
||||
|
||||
# Should detect gpt-oss models
|
||||
assert _is_gpt_oss_model("gpt-oss:20b") is True
|
||||
assert _is_gpt_oss_model("gpt-oss:latest") is True
|
||||
assert _is_gpt_oss_model("gpt-oss") is True
|
||||
assert _is_gpt_oss_model("gpt-oss:7b") is True
|
||||
|
||||
|
||||
# Should not detect non-gpt-oss models
|
||||
assert _is_gpt_oss_model("llama2") is False
|
||||
assert _is_gpt_oss_model("mistral") is False
|
||||
@@ -45,55 +43,57 @@ class TestGptOssModelDetection:
|
||||
|
||||
class TestHarmonyToolConversion:
|
||||
"""Test conversion of tools to Harmony format."""
|
||||
|
||||
|
||||
def test_convert_to_harmony_tool(self) -> None:
|
||||
"""Test that tools are correctly converted to Harmony format."""
|
||||
from langchain_ollama.chat_models import _convert_to_harmony_tool
|
||||
from langchain_core.utils.function_calling import convert_to_openai_tool
|
||||
|
||||
|
||||
from langchain_ollama.chat_models import _convert_to_harmony_tool
|
||||
|
||||
# Convert tool to OpenAI format first
|
||||
openai_tool = convert_to_openai_tool(get_weather)
|
||||
|
||||
|
||||
# Convert to Harmony format
|
||||
harmony_tool = _convert_to_harmony_tool(openai_tool)
|
||||
|
||||
|
||||
# Check structure
|
||||
assert "type" in harmony_tool
|
||||
assert harmony_tool["type"] == "function"
|
||||
assert "function" in harmony_tool
|
||||
|
||||
|
||||
func = harmony_tool["function"]
|
||||
assert "name" in func
|
||||
assert func["name"] == "get_weather"
|
||||
assert "description" in func
|
||||
assert "parameters" in func
|
||||
|
||||
|
||||
params = func["parameters"]
|
||||
assert "type" in params
|
||||
assert params["type"] == "object"
|
||||
assert "properties" in params
|
||||
assert "required" in params
|
||||
|
||||
|
||||
# Check properties
|
||||
props = params["properties"]
|
||||
assert "location" in props
|
||||
assert "unit" in props
|
||||
|
||||
|
||||
# Check that types are strings, not arrays
|
||||
assert isinstance(props["location"]["type"], str)
|
||||
assert props["location"]["type"] == "string"
|
||||
assert isinstance(props["unit"]["type"], str)
|
||||
assert props["unit"]["type"] == "string"
|
||||
|
||||
|
||||
# Check required fields
|
||||
assert "location" in params["required"]
|
||||
assert "unit" not in params["required"] # Has default value
|
||||
|
||||
|
||||
def test_convert_complex_types(self) -> None:
|
||||
"""Test conversion of complex parameter types."""
|
||||
from langchain_ollama.chat_models import _convert_to_harmony_tool
|
||||
from langchain_core.utils.function_calling import convert_to_openai_tool
|
||||
|
||||
|
||||
from langchain_ollama.chat_models import _convert_to_harmony_tool
|
||||
|
||||
@tool
|
||||
def complex_tool(
|
||||
numbers: list[int],
|
||||
@@ -101,27 +101,27 @@ class TestHarmonyToolConversion:
|
||||
optional: str = None,
|
||||
) -> str:
|
||||
"""A tool with complex types.
|
||||
|
||||
|
||||
Args:
|
||||
numbers: List of numbers.
|
||||
data: Dictionary data.
|
||||
optional: Optional string parameter.
|
||||
"""
|
||||
return "result"
|
||||
|
||||
|
||||
openai_tool = convert_to_openai_tool(complex_tool)
|
||||
harmony_tool = _convert_to_harmony_tool(openai_tool)
|
||||
|
||||
|
||||
props = harmony_tool["function"]["parameters"]["properties"]
|
||||
|
||||
|
||||
# Array types should be converted to "array"
|
||||
assert props["numbers"]["type"] == "array"
|
||||
if "items" in props["numbers"]:
|
||||
assert props["numbers"]["items"]["type"] == "integer"
|
||||
|
||||
|
||||
# Object types should be "object"
|
||||
assert props["data"]["type"] == "object"
|
||||
|
||||
|
||||
# Optional parameters with None default should be "string"
|
||||
if "optional" in props:
|
||||
assert props["optional"]["type"] == "string"
|
||||
@@ -129,49 +129,49 @@ class TestHarmonyToolConversion:
|
||||
|
||||
class TestGptOssToolBinding:
|
||||
"""Test tool binding for gpt-oss models."""
|
||||
|
||||
|
||||
def test_bind_tools_with_gpt_oss(self) -> None:
|
||||
"""Test that tools are correctly bound for gpt-oss models."""
|
||||
# Create ChatOllama instance with gpt-oss model
|
||||
llm = ChatOllama(model="gpt-oss:20b")
|
||||
|
||||
|
||||
# Bind tools
|
||||
llm_with_tools = llm.bind_tools([get_weather])
|
||||
|
||||
|
||||
# Check that tools are bound
|
||||
assert hasattr(llm_with_tools, "kwargs")
|
||||
assert "tools" in llm_with_tools.kwargs
|
||||
|
||||
|
||||
# Tools should be in Harmony format
|
||||
tools = llm_with_tools.kwargs["tools"]
|
||||
assert len(tools) == 1
|
||||
|
||||
|
||||
tool_def = tools[0]
|
||||
assert tool_def["type"] == "function"
|
||||
assert "function" in tool_def
|
||||
|
||||
|
||||
# Check parameter types are strings
|
||||
params = tool_def["function"]["parameters"]["properties"]
|
||||
for prop in params.values():
|
||||
if "type" in prop:
|
||||
assert isinstance(prop["type"], str)
|
||||
|
||||
|
||||
def test_bind_tools_with_non_gpt_oss(self) -> None:
|
||||
"""Test that tools use standard format for non-gpt-oss models."""
|
||||
# Create ChatOllama instance with non-gpt-oss model
|
||||
llm = ChatOllama(model="llama2")
|
||||
|
||||
|
||||
# Bind tools
|
||||
llm_with_tools = llm.bind_tools([get_weather])
|
||||
|
||||
|
||||
# Check that tools are bound
|
||||
assert hasattr(llm_with_tools, "kwargs")
|
||||
assert "tools" in llm_with_tools.kwargs
|
||||
|
||||
|
||||
# Tools should be in standard OpenAI format
|
||||
tools = llm_with_tools.kwargs["tools"]
|
||||
assert len(tools) == 1
|
||||
|
||||
|
||||
tool_def = tools[0]
|
||||
assert tool_def["type"] == "function"
|
||||
assert "function" in tool_def
|
||||
@@ -179,107 +179,95 @@ class TestGptOssToolBinding:
|
||||
|
||||
class TestGptOssResponseParsing:
|
||||
"""Test parsing of tool call responses from gpt-oss models."""
|
||||
|
||||
|
||||
def test_parse_standard_tool_response(self) -> None:
|
||||
"""Test parsing standard format tool response."""
|
||||
from langchain_ollama.chat_models import _get_tool_calls_from_response
|
||||
|
||||
|
||||
response = {
|
||||
"message": {
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": '{"location": "London", "unit": "celsius"}'
|
||||
"arguments": '{"location": "London", "unit": "celsius"}',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
tool_calls = _get_tool_calls_from_response(response, model_name="gpt-oss:20b")
|
||||
|
||||
|
||||
assert len(tool_calls) == 1
|
||||
assert tool_calls[0]["name"] == "get_weather"
|
||||
assert tool_calls[0]["args"] == {"location": "London", "unit": "celsius"}
|
||||
|
||||
|
||||
def test_parse_direct_tool_response(self) -> None:
|
||||
"""Test parsing direct format tool response (without nested function)."""
|
||||
from langchain_ollama.chat_models import _get_tool_calls_from_response
|
||||
|
||||
|
||||
response = {
|
||||
"message": {
|
||||
"tool_calls": [
|
||||
{
|
||||
"name": "get_weather",
|
||||
"arguments": '{"location": "Paris"}'
|
||||
}
|
||||
{"name": "get_weather", "arguments": '{"location": "Paris"}'}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
tool_calls = _get_tool_calls_from_response(response, model_name="gpt-oss:20b")
|
||||
|
||||
|
||||
assert len(tool_calls) == 1
|
||||
assert tool_calls[0]["name"] == "get_weather"
|
||||
assert tool_calls[0]["args"] == {"location": "Paris"}
|
||||
|
||||
|
||||
def test_parse_malformed_tool_response(self) -> None:
|
||||
"""Test handling of malformed tool responses."""
|
||||
from langchain_ollama.chat_models import _get_tool_calls_from_response
|
||||
|
||||
|
||||
# Response with invalid JSON in arguments
|
||||
response = {
|
||||
"message": {
|
||||
"tool_calls": [
|
||||
{
|
||||
"name": "get_weather",
|
||||
"arguments": "invalid json"
|
||||
}
|
||||
]
|
||||
"tool_calls": [{"name": "get_weather", "arguments": "invalid json"}]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Should handle gracefully and return empty list or skip invalid calls
|
||||
tool_calls = _get_tool_calls_from_response(response, model_name="gpt-oss:20b")
|
||||
|
||||
|
||||
# Either returns empty list or skips the malformed call
|
||||
assert len(tool_calls) == 0
|
||||
|
||||
|
||||
def test_parse_empty_tool_response(self) -> None:
|
||||
"""Test handling of responses without tool calls."""
|
||||
from langchain_ollama.chat_models import _get_tool_calls_from_response
|
||||
|
||||
response = {
|
||||
"message": {
|
||||
"content": "I'll help you with that."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
response = {"message": {"content": "I'll help you with that."}}
|
||||
|
||||
tool_calls = _get_tool_calls_from_response(response, model_name="gpt-oss:20b")
|
||||
|
||||
|
||||
assert len(tool_calls) == 0
|
||||
|
||||
|
||||
def test_parse_non_gpt_oss_response(self) -> None:
|
||||
"""Test that non-gpt-oss models use standard parsing."""
|
||||
from langchain_ollama.chat_models import _get_tool_calls_from_response
|
||||
|
||||
|
||||
response = {
|
||||
"message": {
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": '{"location": "Tokyo"}'
|
||||
"arguments": '{"location": "Tokyo"}',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Should use standard parsing for non-gpt-oss model
|
||||
tool_calls = _get_tool_calls_from_response(response, model_name="llama2")
|
||||
|
||||
|
||||
assert len(tool_calls) == 1
|
||||
assert tool_calls[0]["name"] == "get_weather"
|
||||
assert tool_calls[0]["args"] == {"location": "Tokyo"}
|
||||
@@ -287,47 +275,48 @@ class TestGptOssResponseParsing:
|
||||
|
||||
class TestGptOssIntegration:
|
||||
"""Integration tests for gpt-oss model with ChatOllama."""
|
||||
|
||||
|
||||
def test_tool_binding_integration(self) -> None:
|
||||
"""Test that tool binding works correctly for gpt-oss models."""
|
||||
# Create ChatOllama with gpt-oss model
|
||||
llm = ChatOllama(model="gpt-oss:20b")
|
||||
|
||||
|
||||
# Define multiple tools using function definitions
|
||||
def search_web(query: str) -> str:
|
||||
"""Search the web for information.
|
||||
|
||||
|
||||
Args:
|
||||
query: The search query.
|
||||
"""
|
||||
return f"Results for {query}"
|
||||
|
||||
|
||||
def calculate(expression: str) -> str:
|
||||
"""Calculate a mathematical expression.
|
||||
|
||||
|
||||
Args:
|
||||
expression: The math expression to evaluate.
|
||||
"""
|
||||
return "42"
|
||||
|
||||
|
||||
# Convert functions to tools using the imported tool decorator
|
||||
from langchain_core.tools import tool as create_tool
|
||||
|
||||
search_tool = create_tool(search_web)
|
||||
calc_tool = create_tool(calculate)
|
||||
|
||||
|
||||
# Bind multiple tools
|
||||
llm_with_tools = llm.bind_tools([get_weather, search_tool, calc_tool])
|
||||
|
||||
|
||||
# Check that all tools are bound correctly
|
||||
assert hasattr(llm_with_tools, "kwargs")
|
||||
assert "tools" in llm_with_tools.kwargs
|
||||
tools = llm_with_tools.kwargs["tools"]
|
||||
assert len(tools) == 3
|
||||
|
||||
|
||||
# Verify each tool is in Harmony format
|
||||
tool_names = {tool["function"]["name"] for tool in tools}
|
||||
assert tool_names == {"get_weather", "search_web", "calculate"}
|
||||
|
||||
|
||||
# Check that all tools have proper Harmony format
|
||||
for tool in tools:
|
||||
assert tool["type"] == "function"
|
||||
@@ -336,28 +325,28 @@ class TestGptOssIntegration:
|
||||
assert "name" in func
|
||||
assert "description" in func
|
||||
assert "parameters" in func
|
||||
|
||||
|
||||
# Verify parameter types are strings
|
||||
if "properties" in func["parameters"]:
|
||||
for prop in func["parameters"]["properties"].values():
|
||||
if "type" in prop:
|
||||
assert isinstance(prop["type"], str)
|
||||
|
||||
|
||||
def test_tool_format_consistency(self) -> None:
|
||||
"""Test that tool format is consistent across multiple bindings."""
|
||||
llm = ChatOllama(model="gpt-oss:20b")
|
||||
|
||||
|
||||
# First binding
|
||||
llm_with_tool1 = llm.bind_tools([get_weather])
|
||||
tools1 = llm_with_tool1.kwargs["tools"]
|
||||
|
||||
|
||||
# Second binding with same tool
|
||||
llm_with_tool2 = llm.bind_tools([get_weather])
|
||||
tools2 = llm_with_tool2.kwargs["tools"]
|
||||
|
||||
|
||||
# Tools should be formatted identically
|
||||
assert tools1 == tools2
|
||||
|
||||
|
||||
# Both should be in Harmony format
|
||||
for tools in [tools1, tools2]:
|
||||
assert len(tools) == 1
|
||||
@@ -372,46 +361,36 @@ class TestGptOssIntegration:
|
||||
|
||||
class TestChatParamsWithGptOss:
|
||||
"""Test _chat_params method with gpt-oss models."""
|
||||
|
||||
|
||||
def test_chat_params_with_harmony_tools(self) -> None:
|
||||
"""Test that _chat_params correctly formats tools for gpt-oss."""
|
||||
llm = ChatOllama(model="gpt-oss:20b")
|
||||
|
||||
|
||||
# Bind tools - this adds tools to kwargs
|
||||
llm_with_tools = llm.bind_tools([get_weather])
|
||||
|
||||
|
||||
# Get chat params - tools are passed through kwargs
|
||||
messages = [HumanMessage(content="What's the weather?")]
|
||||
|
||||
|
||||
# The tools are in the bound model's kwargs
|
||||
assert hasattr(llm_with_tools, "kwargs")
|
||||
assert "tools" in llm_with_tools.kwargs
|
||||
|
||||
|
||||
# When _chat_params is called, it should include the tools from kwargs
|
||||
params = llm_with_tools._chat_params(messages, **llm_with_tools.kwargs)
|
||||
|
||||
|
||||
# Check that tools are included and in Harmony format
|
||||
assert "tools" in params
|
||||
tools = params["tools"]
|
||||
assert len(tools) == 1
|
||||
|
||||
|
||||
# Verify Harmony format
|
||||
tool = tools[0]
|
||||
assert tool["type"] == "function"
|
||||
assert "function" in tool
|
||||
|
||||
|
||||
# Check that parameter types are strings
|
||||
props = tool["function"]["parameters"]["properties"]
|
||||
for prop in props.values():
|
||||
if "type" in prop:
|
||||
assert isinstance(prop["type"], str)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user