diff --git a/haystack/components/generators/chat/azure.py b/haystack/components/generators/chat/azure.py index cef70bea64..f6c452d238 100644 --- a/haystack/components/generators/chat/azure.py +++ b/haystack/components/generators/chat/azure.py @@ -18,6 +18,7 @@ deserialize_tools_or_toolset_inplace, flatten_tools_or_toolsets, serialize_tools_or_toolset, + warm_up_tools, ) from haystack.utils import Secret, deserialize_callable, deserialize_secrets_inplace, serialize_callable from haystack.utils.http_client import init_http_client @@ -201,6 +202,18 @@ def __init__( # pylint: disable=too-many-positional-arguments self.async_client = AsyncAzureOpenAI( http_client=init_http_client(self.http_client_kwargs, async_client=True), **client_args ) + self._is_warmed_up = False + + def warm_up(self): + """ + Warm up the Azure OpenAI chat generator. + + This will warm up the tools registered in the chat generator. + This method is idempotent and will only warm up the tools once. + """ + if not self._is_warmed_up: + warm_up_tools(self.tools) + self._is_warmed_up = True def to_dict(self) -> dict[str, Any]: """ diff --git a/haystack/components/generators/chat/fallback.py b/haystack/components/generators/chat/fallback.py index ee5008697b..26f4f3a84f 100644 --- a/haystack/components/generators/chat/fallback.py +++ b/haystack/components/generators/chat/fallback.py @@ -81,6 +81,16 @@ def from_dict(cls, data: dict[str, Any]) -> FallbackChatGenerator: data["init_parameters"] = init_params return default_from_dict(cls, data) + def warm_up(self) -> None: + """ + Warm up all underlying chat generators. + + This method calls warm_up() on each underlying generator that supports it. + """ + for gen in self.chat_generators: + if hasattr(gen, "warm_up") and callable(gen.warm_up): + gen.warm_up() + def _run_single_sync( # pylint: disable=too-many-positional-arguments self, gen: Any, diff --git a/haystack/components/generators/chat/hugging_face_api.py b/haystack/components/generators/chat/hugging_face_api.py index ac30d63998..fb6d6dbb3b 100644 --- a/haystack/components/generators/chat/hugging_face_api.py +++ b/haystack/components/generators/chat/hugging_face_api.py @@ -26,6 +26,7 @@ deserialize_tools_or_toolset_inplace, flatten_tools_or_toolsets, serialize_tools_or_toolset, + warm_up_tools, ) from haystack.utils import Secret, deserialize_callable, deserialize_secrets_inplace, serialize_callable from haystack.utils.hf import HFGenerationAPIType, HFModelType, check_valid_model, convert_message_to_hf_format @@ -384,6 +385,18 @@ def __init__( # pylint: disable=too-many-positional-arguments model_or_url, token=token.resolve_value() if token else None, **resolved_api_params ) self.tools = tools + self._is_warmed_up = False + + def warm_up(self): + """ + Warm up the Hugging Face API chat generator. + + This will warm up the tools registered in the chat generator. + This method is idempotent and will only warm up the tools once. + """ + if not self._is_warmed_up: + warm_up_tools(self.tools) + self._is_warmed_up = True def to_dict(self) -> dict[str, Any]: """ diff --git a/haystack/components/generators/chat/hugging_face_local.py b/haystack/components/generators/chat/hugging_face_local.py index 6c50956cdc..2296b4f7f1 100644 --- a/haystack/components/generators/chat/hugging_face_local.py +++ b/haystack/components/generators/chat/hugging_face_local.py @@ -23,6 +23,7 @@ flatten_tools_or_toolsets, serialize_tools_or_toolset, ) +from haystack.tools.utils import warm_up_tools from haystack.utils import ( ComponentDevice, Secret, @@ -249,6 +250,7 @@ def __init__( # pylint: disable=too-many-positional-arguments if async_executor is None else async_executor ) + self._is_warmed_up = False def __del__(self) -> None: """ @@ -274,11 +276,21 @@ def _get_telemetry_data(self) -> dict[str, Any]: def warm_up(self) -> None: """ - Initializes the component. + Initializes the component and warms up tools if provided. """ + if self._is_warmed_up: + return + + # Initialize the pipeline (existing logic) if self.pipeline is None: self.pipeline = pipeline(**self.huggingface_pipeline_kwargs) + # Warm up tools (new logic) + if self.tools: + warm_up_tools(self.tools) + + self._is_warmed_up = True + def to_dict(self) -> dict[str, Any]: """ Serializes the component to a dictionary. diff --git a/haystack/components/generators/chat/openai.py b/haystack/components/generators/chat/openai.py index 4d4e392690..59eb878a30 100644 --- a/haystack/components/generators/chat/openai.py +++ b/haystack/components/generators/chat/openai.py @@ -41,6 +41,7 @@ deserialize_tools_or_toolset_inplace, flatten_tools_or_toolsets, serialize_tools_or_toolset, + warm_up_tools, ) from haystack.utils import Secret, deserialize_callable, deserialize_secrets_inplace, serialize_callable from haystack.utils.http_client import init_http_client @@ -200,6 +201,18 @@ def __init__( # pylint: disable=too-many-positional-arguments self.async_client = AsyncOpenAI( http_client=init_http_client(self.http_client_kwargs, async_client=True), **client_kwargs ) + self._is_warmed_up = False + + def warm_up(self): + """ + Warm up the OpenAI chat generator. + + This will warm up the tools registered in the chat generator. + This method is idempotent and will only warm up the tools once. + """ + if not self._is_warmed_up: + warm_up_tools(self.tools) + self._is_warmed_up = True def _get_telemetry_data(self) -> dict[str, Any]: """ diff --git a/releasenotes/notes/add-warm-up-to-chat-generators-cb0fa9429d721074.yaml b/releasenotes/notes/add-warm-up-to-chat-generators-cb0fa9429d721074.yaml new file mode 100644 index 0000000000..744eaf8d50 --- /dev/null +++ b/releasenotes/notes/add-warm-up-to-chat-generators-cb0fa9429d721074.yaml @@ -0,0 +1,9 @@ +--- +features: + - | + Added warm_up() method to all ChatGenerator components (OpenAIChatGenerator, + AzureOpenAIChatGenerator, HuggingFaceAPIChatGenerator, HuggingFaceLocalChatGenerator, + and FallbackChatGenerator) to properly initialize tools that require warm-up before + pipeline execution. The warm_up() method is idempotent and follows the same pattern + used in Agent and ToolInvoker components. This enables proper tool initialization + in pipelines that use ChatGenerators with tools but without an Agent component. diff --git a/test/components/generators/chat/test_azure.py b/test/components/generators/chat/test_azure.py index 4da2ef5d80..1e9bf8cbc1 100644 --- a/test/components/generators/chat/test_azure.py +++ b/test/components/generators/chat/test_azure.py @@ -428,6 +428,110 @@ def test_to_dict_with_toolset(self, tools, monkeypatch): } assert data["init_parameters"]["tools"] == expected_tools_data + def test_warm_up_with_tools(self, monkeypatch): + """Test that warm_up() calls warm_up on tools and is idempotent.""" + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key") + + # Create a mock tool that tracks if warm_up() was called + class MockTool(Tool): + warm_up_call_count = 0 # Class variable to track calls + + def __init__(self): + super().__init__( + name="mock_tool", + description="A mock tool for testing", + parameters={"x": {"type": "string"}}, + function=lambda x: x, + ) + + def warm_up(self): + MockTool.warm_up_call_count += 1 + + # Reset the class variable before test + MockTool.warm_up_call_count = 0 + mock_tool = MockTool() + + # Create AzureOpenAIChatGenerator with the mock tool + component = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint", tools=[mock_tool]) + + # Verify initial state - warm_up not called yet + assert MockTool.warm_up_call_count == 0 + assert not component._is_warmed_up + + # Call warm_up() on the generator + component.warm_up() + + # Assert that the tool's warm_up() was called + assert MockTool.warm_up_call_count == 1 + assert component._is_warmed_up + + # Call warm_up() again and verify it's idempotent (only warms up once) + component.warm_up() + + # The tool's warm_up should still only have been called once + assert MockTool.warm_up_call_count == 1 + assert component._is_warmed_up + + def test_warm_up_with_no_tools(self, monkeypatch): + """Test that warm_up() works when no tools are provided.""" + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key") + + component = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint") + + # Verify initial state + assert not component._is_warmed_up + assert component.tools is None + + # Call warm_up() - should not raise an error + component.warm_up() + + # Verify the component is warmed up + assert component._is_warmed_up + + # Call warm_up() again - should be idempotent + component.warm_up() + assert component._is_warmed_up + + def test_warm_up_with_multiple_tools(self, monkeypatch): + """Test that warm_up() works with multiple tools.""" + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key") + + # Track warm_up calls + warm_up_calls = [] + + class MockTool(Tool): + def __init__(self, tool_name): + super().__init__( + name=tool_name, + description=f"Mock tool {tool_name}", + parameters={"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]}, + function=lambda x: f"{tool_name} result: {x}", + ) + + def warm_up(self): + warm_up_calls.append(self.name) + + mock_tool1 = MockTool("tool1") + mock_tool2 = MockTool("tool2") + + # Use a LIST of tools, not a Toolset + component = AzureOpenAIChatGenerator( + azure_endpoint="some-non-existing-endpoint", tools=[mock_tool1, mock_tool2] + ) + + # Call warm_up() + component.warm_up() + + # Assert that both tools' warm_up() were called + assert "tool1" in warm_up_calls + assert "tool2" in warm_up_calls + assert component._is_warmed_up + + # Test idempotency - warm_up should not call tools again + initial_count = len(warm_up_calls) + component.warm_up() + assert len(warm_up_calls) == initial_count + class TestAzureOpenAIChatGeneratorAsync: def test_init_should_also_create_async_client_with_same_args(self, tools): diff --git a/test/components/generators/chat/test_fallback.py b/test/components/generators/chat/test_fallback.py index 5bc2a91c47..7861cbfa28 100644 --- a/test/components/generators/chat/test_fallback.py +++ b/test/components/generators/chat/test_fallback.py @@ -354,3 +354,71 @@ async def test_failover_trigger_401_authentication_async(): assert result["replies"][0].text == "success_after_auth" assert result["meta"]["successful_chat_generator_index"] == 1 assert result["meta"]["failed_chat_generators"] == ["_DummyHTTPErrorGen"] + + +@component +class _DummyGenWithWarmUp: + """Dummy generator that tracks warm_up calls.""" + + def __init__(self, text: str = "ok"): + self.text = text + self.warm_up_called = False + + def warm_up(self) -> None: + self.warm_up_called = True + + def run( + self, + messages: list[ChatMessage], + generation_kwargs: Optional[dict[str, Any]] = None, + tools: Optional[ToolsType] = None, + streaming_callback: Optional[StreamingCallbackT] = None, + ) -> dict[str, Any]: + return {"replies": [ChatMessage.from_assistant(self.text)], "meta": {}} + + +def test_warm_up_delegates_to_generators(): + """Test that warm_up() is called on each underlying generator.""" + gen1 = _DummyGenWithWarmUp(text="A") + gen2 = _DummyGenWithWarmUp(text="B") + gen3 = _DummyGenWithWarmUp(text="C") + + fallback = FallbackChatGenerator(chat_generators=[gen1, gen2, gen3]) + fallback.warm_up() + + assert gen1.warm_up_called + assert gen2.warm_up_called + assert gen3.warm_up_called + + +def test_warm_up_with_no_warm_up_method(): + """Test that warm_up() handles generators without warm_up() gracefully.""" + gen1 = _DummySuccessGen(text="A") + gen2 = _DummySuccessGen(text="B") + + fallback = FallbackChatGenerator(chat_generators=[gen1, gen2]) + # Should not raise any error + fallback.warm_up() + + # Verify generators still work + result = fallback.run([ChatMessage.from_user("test")]) + assert result["replies"][0].text == "A" + + +def test_warm_up_mixed_generators(): + """Test warm_up() with a mix of generators with and without warm_up().""" + gen1 = _DummyGenWithWarmUp(text="A") + gen2 = _DummySuccessGen(text="B") + gen3 = _DummyGenWithWarmUp(text="C") + gen4 = _DummyFailGen() + + fallback = FallbackChatGenerator(chat_generators=[gen1, gen2, gen3, gen4]) + fallback.warm_up() + + # Only generators with warm_up() should have been called + assert gen1.warm_up_called + assert gen3.warm_up_called + + # Verify the fallback still works correctly + result = fallback.run([ChatMessage.from_user("test")]) + assert result["replies"][0].text == "A" diff --git a/test/components/generators/chat/test_hugging_face_api.py b/test/components/generators/chat/test_hugging_face_api.py index 115bc12b82..ee9e82f8d0 100644 --- a/test/components/generators/chat/test_hugging_face_api.py +++ b/test/components/generators/chat/test_hugging_face_api.py @@ -1176,3 +1176,112 @@ def test_convert_tools_to_hfapi_tools_legacy(self): arguments={"city": {"type": "string"}}, description="useful to determine the weather in a given location", ) + + def test_warm_up_with_tools(self, mock_check_valid_model): + """Test that warm_up() calls warm_up on tools and is idempotent.""" + + # Create a mock tool that tracks if warm_up() was called + class MockTool(Tool): + warm_up_call_count = 0 # Class variable to track calls + + def __init__(self): + super().__init__( + name="mock_tool", + description="A mock tool for testing", + parameters={"x": {"type": "string"}}, + function=lambda x: x, + ) + + def warm_up(self): + MockTool.warm_up_call_count += 1 + + # Reset the class variable before test + MockTool.warm_up_call_count = 0 + mock_tool = MockTool() + + # Create HuggingFaceAPIChatGenerator with the mock tool + component = HuggingFaceAPIChatGenerator( + api_type=HFGenerationAPIType.SERVERLESS_INFERENCE_API, + api_params={"model": "HuggingFaceH4/zephyr-7b-alpha"}, + tools=[mock_tool], + ) + + # Verify initial state - warm_up not called yet + assert MockTool.warm_up_call_count == 0 + assert not component._is_warmed_up + + # Call warm_up() on the generator + component.warm_up() + + # Assert that the tool's warm_up() was called + assert MockTool.warm_up_call_count == 1 + assert component._is_warmed_up + + # Call warm_up() again and verify it's idempotent (only warms up once) + component.warm_up() + + # The tool's warm_up should still only have been called once + assert MockTool.warm_up_call_count == 1 + assert component._is_warmed_up + + def test_warm_up_with_no_tools(self, mock_check_valid_model): + """Test that warm_up() works when no tools are provided.""" + component = HuggingFaceAPIChatGenerator( + api_type=HFGenerationAPIType.SERVERLESS_INFERENCE_API, api_params={"model": "HuggingFaceH4/zephyr-7b-alpha"} + ) + + # Verify initial state + assert not component._is_warmed_up + assert component.tools is None + + # Call warm_up() - should not raise an error + component.warm_up() + + # Verify the component is warmed up + assert component._is_warmed_up + + # Call warm_up() again - should be idempotent + component.warm_up() + assert component._is_warmed_up + + def test_warm_up_with_multiple_tools(self, mock_check_valid_model): + """Test that warm_up() works with multiple tools.""" + # Track warm_up calls + warm_up_calls = [] + + class MockTool(Tool): + def __init__(self, tool_name): + super().__init__( + name=tool_name, + description=f"Mock tool {tool_name}", + parameters={"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]}, + function=lambda x: f"{tool_name} result: {x}", + ) + + def warm_up(self): + warm_up_calls.append(self.name) + + mock_tool1 = MockTool("tool1") + mock_tool2 = MockTool("tool2") + + # Use a LIST of tools, not a Toolset + component = HuggingFaceAPIChatGenerator( + api_type=HFGenerationAPIType.SERVERLESS_INFERENCE_API, + api_params={"model": "HuggingFaceH4/zephyr-7b-alpha"}, + tools=[mock_tool1, mock_tool2], + ) + + # Call warm_up() + component.warm_up() + + # Assert that both tools' warm_up() were called + assert "tool1" in warm_up_calls + assert "tool2" in warm_up_calls + assert component._is_warmed_up + + # Track count + call_count = len(warm_up_calls) + + # Verify idempotency + component.warm_up() + assert len(warm_up_calls) == call_count diff --git a/test/components/generators/chat/test_hugging_face_local.py b/test/components/generators/chat/test_hugging_face_local.py index 76b97f63e5..78f24073a4 100644 --- a/test/components/generators/chat/test_hugging_face_local.py +++ b/test/components/generators/chat/test_hugging_face_local.py @@ -251,6 +251,141 @@ def test_warm_up(self, pipeline_mock, monkeypatch): model="mistralai/Mistral-7B-Instruct-v0.2", task="text2text-generation", token=None, device="cpu" ) + @patch("haystack.components.generators.chat.hugging_face_local.pipeline") + def test_warm_up_with_tools(self, pipeline_mock, monkeypatch): + """Test that warm_up() calls warm_up on tools and is idempotent.""" + monkeypatch.delenv("HF_API_TOKEN", raising=False) + monkeypatch.delenv("HF_TOKEN", raising=False) + + # Create a mock tool that tracks if warm_up() was called + class MockTool(Tool): + warm_up_call_count = 0 # Class variable to track calls + + def __init__(self): + super().__init__( + name="mock_tool", + description="A mock tool for testing", + parameters={"x": {"type": "string"}}, + function=lambda x: x, + ) + + def warm_up(self): + MockTool.warm_up_call_count += 1 + + # Reset the class variable before test + MockTool.warm_up_call_count = 0 + mock_tool = MockTool() + + # Create HuggingFaceLocalChatGenerator with the mock tool + generator = HuggingFaceLocalChatGenerator( + model="mistralai/Mistral-7B-Instruct-v0.2", + task="text2text-generation", + device=ComponentDevice.from_str("cpu"), + tools=[mock_tool], + ) + + # Verify initial state - warm_up not called yet + assert MockTool.warm_up_call_count == 0 + assert not generator._is_warmed_up + + # Call warm_up() on the generator + generator.warm_up() + + # Assert that the tool's warm_up() was called + assert MockTool.warm_up_call_count == 1 + assert generator._is_warmed_up + + # Verify pipeline was initialized + pipeline_mock.assert_called_once() + + # Call warm_up() again and verify it's idempotent (only warms up once) + generator.warm_up() + + # The tool's warm_up should still only have been called once + assert MockTool.warm_up_call_count == 1 + assert generator._is_warmed_up + # Pipeline should still only have been called once + pipeline_mock.assert_called_once() + + @patch("haystack.components.generators.chat.hugging_face_local.pipeline") + def test_warm_up_with_no_tools(self, pipeline_mock, monkeypatch): + """Test that warm_up() works when no tools are provided.""" + monkeypatch.delenv("HF_API_TOKEN", raising=False) + monkeypatch.delenv("HF_TOKEN", raising=False) + + generator = HuggingFaceLocalChatGenerator( + model="mistralai/Mistral-7B-Instruct-v0.2", + task="text2text-generation", + device=ComponentDevice.from_str("cpu"), + ) + + # Verify initial state + assert not generator._is_warmed_up + assert generator.tools is None + + # Call warm_up() - should not raise an error + generator.warm_up() + + # Verify the component is warmed up + assert generator._is_warmed_up + pipeline_mock.assert_called_once() + + # Call warm_up() again - should be idempotent + generator.warm_up() + assert generator._is_warmed_up + # Pipeline should still only have been called once + pipeline_mock.assert_called_once() + + @patch("haystack.components.generators.chat.hugging_face_local.pipeline") + def test_warm_up_with_multiple_tools(self, pipeline_mock, monkeypatch): + """Test that warm_up() works with multiple tools.""" + monkeypatch.delenv("HF_API_TOKEN", raising=False) + monkeypatch.delenv("HF_TOKEN", raising=False) + + # Track warm_up calls + warm_up_calls = [] + + class MockTool(Tool): + def __init__(self, tool_name): + super().__init__( + name=tool_name, + description=f"Mock tool {tool_name}", + parameters={"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]}, + function=lambda x: f"{tool_name} result: {x}", + ) + + def warm_up(self): + warm_up_calls.append(self.name) + + mock_tool1 = MockTool("tool1") + mock_tool2 = MockTool("tool2") + + # Use a LIST of tools, not a Toolset + generator = HuggingFaceLocalChatGenerator( + model="mistralai/Mistral-7B-Instruct-v0.2", + task="text2text-generation", + device=ComponentDevice.from_str("cpu"), + tools=[mock_tool1, mock_tool2], + ) + + # Call warm_up() + generator.warm_up() + + # Assert that both tools' warm_up() were called + assert "tool1" in warm_up_calls + assert "tool2" in warm_up_calls + assert generator._is_warmed_up + pipeline_mock.assert_called_once() + + # Track count + call_count = len(warm_up_calls) + + # Verify idempotency + generator.warm_up() + assert len(warm_up_calls) == call_count + # Pipeline should still only have been called once + pipeline_mock.assert_called_once() + def test_run(self, model_info_mock, mock_pipeline_with_tokenizer, chat_messages): generator = HuggingFaceLocalChatGenerator(model="meta-llama/Llama-2-13b-chat-hf") diff --git a/test/components/generators/chat/test_openai.py b/test/components/generators/chat/test_openai.py index e1513d7058..5dea3584b9 100644 --- a/test/components/generators/chat/test_openai.py +++ b/test/components/generators/chat/test_openai.py @@ -1153,6 +1153,112 @@ def test_serde_with_list_of_toolsets(self, monkeypatch, tools): assert len(deserialized.tools) == 2 assert all(isinstance(ts, Toolset) for ts in deserialized.tools) + def test_warm_up_with_tools(self, monkeypatch): + """Test that warm_up() calls warm_up on tools and is idempotent.""" + monkeypatch.setenv("OPENAI_API_KEY", "test-api-key") + + # Create a mock tool that tracks if warm_up() was called + class MockTool(Tool): + warm_up_call_count = 0 # Class variable to track calls + + def __init__(self): + super().__init__( + name="mock_tool", + description="A mock tool for testing", + parameters={"x": {"type": "string"}}, + function=lambda x: x, + ) + + def warm_up(self): + MockTool.warm_up_call_count += 1 + + # Reset the class variable before test + MockTool.warm_up_call_count = 0 + mock_tool = MockTool() + + # Create OpenAIChatGenerator with the mock tool + component = OpenAIChatGenerator(tools=[mock_tool]) + + # Verify initial state - warm_up not called yet + assert MockTool.warm_up_call_count == 0 + assert not component._is_warmed_up + + # Call warm_up() on the generator + component.warm_up() + + # Assert that the tool's warm_up() was called + assert MockTool.warm_up_call_count == 1 + assert component._is_warmed_up + + # Call warm_up() again and verify it's idempotent (only warms up once) + component.warm_up() + + # The tool's warm_up should still only have been called once + assert MockTool.warm_up_call_count == 1 + assert component._is_warmed_up + + def test_warm_up_with_no_tools(self, monkeypatch): + """Test that warm_up() works when no tools are provided.""" + monkeypatch.setenv("OPENAI_API_KEY", "test-api-key") + + component = OpenAIChatGenerator() + + # Verify initial state + assert not component._is_warmed_up + assert component.tools is None + + # Call warm_up() - should not raise an error + component.warm_up() + + # Verify the component is warmed up + assert component._is_warmed_up + + # Call warm_up() again - should be idempotent + component.warm_up() + assert component._is_warmed_up + + def test_warm_up_with_multiple_tools(self, monkeypatch): + """Test that warm_up() works with multiple tools.""" + monkeypatch.setenv("OPENAI_API_KEY", "test-api-key") + + from haystack.tools import Tool + + # Track warm_up calls + warm_up_calls = [] + + class MockTool(Tool): + def __init__(self, tool_name): + super().__init__( + name=tool_name, + description=f"Mock tool {tool_name}", + parameters={"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]}, + function=lambda x: f"{tool_name} result: {x}", + ) + + def warm_up(self): + warm_up_calls.append(self.name) + + mock_tool1 = MockTool("tool1") + mock_tool2 = MockTool("tool2") + + # Use a LIST of tools, not a Toolset + component = OpenAIChatGenerator(tools=[mock_tool1, mock_tool2]) + + # Call warm_up() + component.warm_up() + + # Assert that both tools' warm_up() were called + assert "tool1" in warm_up_calls + assert "tool2" in warm_up_calls + assert component._is_warmed_up + + # Track count + call_count = len(warm_up_calls) + + # Verify idempotency + component.warm_up() + assert len(warm_up_calls) == call_count + @pytest.fixture def chat_completion_chunks():