From 2d953ca4031d529f6df6505787a900d37e5cc1dd Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Wed, 6 Aug 2025 16:57:01 +0000 Subject: [PATCH] Apply patch [skip ci] --- .../ollama/langchain_ollama/chat_models.py | 75 +++---- libs/partners/ollama/test_gpt_oss.py | 40 ++-- .../integration_tests/test_gpt_oss_tools.py | 134 ++++++------ .../unit_tests/test_chat_models_gpt_oss.py | 197 ++++++++---------- 4 files changed, 200 insertions(+), 246 deletions(-) diff --git a/libs/partners/ollama/langchain_ollama/chat_models.py b/libs/partners/ollama/langchain_ollama/chat_models.py index 6e9257fd401..2b3ef00c800 100644 --- a/libs/partners/ollama/langchain_ollama/chat_models.py +++ b/libs/partners/ollama/langchain_ollama/chat_models.py @@ -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 - - - - - - - - - - - - - - diff --git a/libs/partners/ollama/test_gpt_oss.py b/libs/partners/ollama/test_gpt_oss.py index 4cf022c6588..80603c6d45f 100644 --- a/libs/partners/ollama/test_gpt_oss.py +++ b/libs/partners/ollama/test_gpt_oss.py @@ -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 :") + print(' template: :108:130: executing "" at :') print(" error calling index: reflect: slice index out of range") else: print("\n⚠️ Basic functionality test failed!") diff --git a/libs/partners/ollama/tests/integration_tests/test_gpt_oss_tools.py b/libs/partners/ollama/tests/integration_tests/test_gpt_oss_tools.py index d748ff19676..065a5a2a059 100644 --- a/libs/partners/ollama/tests/integration_tests/test_gpt_oss_tools.py +++ b/libs/partners/ollama/tests/integration_tests/test_gpt_oss_tools.py @@ -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 diff --git a/libs/partners/ollama/tests/unit_tests/test_chat_models_gpt_oss.py b/libs/partners/ollama/tests/unit_tests/test_chat_models_gpt_oss.py index a575442e2ad..9c15a521701 100644 --- a/libs/partners/ollama/tests/unit_tests/test_chat_models_gpt_oss.py +++ b/libs/partners/ollama/tests/unit_tests/test_chat_models_gpt_oss.py @@ -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) - - - - - - - - - -