Apply patch [skip ci]

This commit is contained in:
open-swe[bot]
2025-08-06 16:57:01 +00:00
parent 4abc2319c0
commit 2d953ca403
4 changed files with 200 additions and 246 deletions

View File

@@ -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

View File

@@ -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!")

View File

@@ -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

View File

@@ -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)