From 5bfe3960416e5373124649971a56de7092364901 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Wed, 4 Feb 2026 12:27:55 +0000 Subject: [PATCH 1/2] fix(smolagents): Reuse AnyLLM client instance to avoid event loop errors Create an AnyLLM client instance once via AnyLLM.create() instead of returning the any_llm module from create_client(). This fixes "Event loop is closed" errors that occurred when making multiple completion calls, as the functional API created per-call async resources tied to an event loop that could be closed between calls. Changes: - Parse model_id using AnyLLM.split_model_provider() to extract provider - Store provider config separately from completion kwargs - Create AnyLLM instance in create_client() for reuse by ApiModel - Add error handling for malformed model_id with clear message - Add unit tests for AnyLLMModel class including regression test Fixes #824 --- src/any_agent/frameworks/smolagents.py | 67 ++++++++++++++++---- src/any_agent/testing/helpers.py | 2 +- tests/unit/frameworks/test_smolagents.py | 81 ++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 15 deletions(-) diff --git a/src/any_agent/frameworks/smolagents.py b/src/any_agent/frameworks/smolagents.py index 19208a9b..8e9cafd0 100644 --- a/src/any_agent/frameworks/smolagents.py +++ b/src/any_agent/frameworks/smolagents.py @@ -30,6 +30,7 @@ smolagents_available = False if TYPE_CHECKING: + from any_llm import AnyLLM from any_llm.types.completion import ChatCompletion, ChatCompletionChunk from smolagents import MultiStepAgent @@ -55,6 +56,29 @@ def __init__( flatten_messages_as_text: bool | None = None, **kwargs: Any, ) -> None: + from any_llm import AnyLLM as AnyLLMClass + + try: + provider, model = AnyLLMClass.split_model_provider(model_id) + except ValueError as e: + msg = ( + f"Invalid model_id format: {model_id}. " + "Expected 'provider:model' (e.g., 'openai:gpt-4o')." + ) + raise ValueError(msg) from e + + # Store provider config for client creation (must be set before + # super().__init__ because ApiModel calls create_client()). + self._provider = provider + self._api_key = api_key + self._api_base = api_base + + # Store model and kwargs for completion calls. + self._anyllm_completion_kwargs: dict[str, Any] = { + "model": model, + **kwargs, + } + super().__init__( model_id=model_id, custom_role_conversions=custom_role_conversions, @@ -62,18 +86,25 @@ def __init__( **kwargs, ) - self._anyllm_common_kwargs: dict[str, Any] = { - "model": model_id, - "api_key": api_key, - "api_base": api_base, - **kwargs, - } + def create_client(self) -> "AnyLLM": + """Create the any-llm client, required method for ApiModel subclasses. + + The client is instantiated once (by ApiModel.__init__) and reused for + all completion calls. This avoids "Event loop is closed" errors that + occur when using the functional API, which creates per-call async + resources tied to an event loop that may be closed between calls. - def create_client(self) -> Any: - """Create the any-llm client, required method for ApiModel subclasses.""" - import any_llm + Note: We rely on ApiModel caching self.client rather than implementing + caching here. This follows the pattern used by tinyagent and openai + framework implementations. + """ + from any_llm import AnyLLM as AnyLLMClass - return any_llm + return AnyLLMClass.create( + provider=self._provider, + api_key=self._api_key, + api_base=self._api_base, + ) def _prepare_completion_kwargs( self, @@ -133,8 +164,11 @@ def generate( convert_images_to_image_urls=True, **kwargs, ) - payload = {**self._anyllm_common_kwargs, **completion_kwargs} - response: ChatCompletion = self.client.completion(**payload) + payload = {**self._anyllm_completion_kwargs, **completion_kwargs} + # allow_running_loop=True permits sync completion calls in async contexts. + response: ChatCompletion = self.client.completion( + **payload, allow_running_loop=True + ) return ChatMessage.from_dict( response.choices[0].message.model_dump( @@ -169,9 +203,14 @@ def generate_stream( # Ensure usage information is included in the streamed chunks when the provider supports it completion_kwargs.setdefault("stream_options", {"include_usage": True}) - payload = {**self._anyllm_common_kwargs, **completion_kwargs, "stream": True} + payload = { + **self._anyllm_completion_kwargs, + **completion_kwargs, + "stream": True, + } + # allow_running_loop=True permits sync completion calls in async contexts. response_iterator: Generator[ChatCompletionChunk] = self.client.completion( - **payload + **payload, allow_running_loop=True ) for event in response_iterator: diff --git a/src/any_agent/testing/helpers.py b/src/any_agent/testing/helpers.py index b09c433f..e3db8cbb 100644 --- a/src/any_agent/testing/helpers.py +++ b/src/any_agent/testing/helpers.py @@ -17,7 +17,7 @@ AgentFramework.TINYAGENT: "any_llm.AnyLLM.acompletion", AgentFramework.AGNO: "any_agent.frameworks.agno.acompletion", AgentFramework.OPENAI: "any_llm.AnyLLM.acompletion", - AgentFramework.SMOLAGENTS: "any_llm.completion", + AgentFramework.SMOLAGENTS: "any_llm.AnyLLM.acompletion", AgentFramework.LLAMA_INDEX: "any_llm.AnyLLM.acompletion", } diff --git a/tests/unit/frameworks/test_smolagents.py b/tests/unit/frameworks/test_smolagents.py index daab583a..2e1e7e7d 100644 --- a/tests/unit/frameworks/test_smolagents.py +++ b/tests/unit/frameworks/test_smolagents.py @@ -3,6 +3,7 @@ import pytest from any_agent import AgentConfig, AgentFramework, AnyAgent +from any_agent.frameworks.smolagents import AnyLLMModel def test_load_smolagent_default() -> None: @@ -111,3 +112,83 @@ def test_run_smolagent_custom_args() -> None: ) agent.run("foo", max_steps=30) mock_agent.return_value.run.assert_called_once_with("foo", max_steps=30) + + +class TestAnyLLMModel: + """Tests for AnyLLMModel class directly.""" + + def test_malformed_model_id_raises_clear_error(self) -> None: + """Test that malformed model_id raises ValueError with helpful message.""" + with pytest.raises(ValueError, match="Invalid model_id format"): + AnyLLMModel(model_id="invalid-no-provider") + + def test_parses_model_id_correctly(self) -> None: + """Test that model_id is parsed into provider and model.""" + with patch("any_llm.AnyLLM.create"): + model = AnyLLMModel( + model_id="openai:gpt-4o", + api_key="test-key", + api_base="https://api.example.com", + ) + + assert model._provider.value == "openai" + assert model._anyllm_completion_kwargs["model"] == "gpt-4o" + assert model._api_key == "test-key" + assert model._api_base == "https://api.example.com" + + def test_create_client_creates_anyllm_instance(self) -> None: + """Test that create_client() creates an AnyLLM instance with correct args.""" + mock_anyllm_create = MagicMock() + + with patch("any_llm.AnyLLM.create", mock_anyllm_create): + model = AnyLLMModel( + model_id="anthropic:claude-sonnet-4-20250514", + api_key="test-key", + api_base="https://api.example.com", + ) + # create_client is called by ApiModel.__init__, but we can call it again. + model.create_client() + + # Verify create was called with correct provider config. + mock_anyllm_create.assert_called_with( + provider=model._provider, + api_key="test-key", + api_base="https://api.example.com", + ) + + def test_client_reused_across_multiple_calls(self) -> None: + """Regression test for GitHub issue #824. + + The original implementation returned the any_llm module from create_client(), + causing each completion to go through the functional API. This led to + "Event loop is closed" errors on subsequent calls. The fix creates an + AnyLLM instance once and reuses it. + + This test verifies: + 1. AnyLLM.create() is called exactly once during initialization + 2. The client instance is stored and reusable for multiple calls + """ + mock_client = MagicMock() + mock_create = MagicMock(return_value=mock_client) + + with patch("any_llm.AnyLLM.create", mock_create): + model = AnyLLMModel(model_id="openai:gpt-4o") + + # Verify AnyLLM.create was called exactly once during init. + assert mock_create.call_count == 1 + + # Verify the client is the mock we provided. + assert model.client is mock_client + + # Simulate multiple completion calls (the scenario that caused the bug). + # In the old implementation, each call would go through the functional + # API and potentially create new event loop resources. + model.client.completion(messages=[{"role": "user", "content": "Hello"}]) + model.client.completion(messages=[{"role": "user", "content": "World"}]) + model.client.completion(messages=[{"role": "user", "content": "Test"}]) + + # Verify all calls went to the same client instance. + assert mock_client.completion.call_count == 3 + + # Verify AnyLLM.create was NOT called again. + assert mock_create.call_count == 1 From 5d87019af765418d7daddf7daac8a84389a215d6 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Wed, 4 Feb 2026 17:13:53 +0000 Subject: [PATCH 2/2] fix(tests): Address PR review feedback - Remove unnecessary create_client() call in test (already called by ApiModel.__init__) - Remove redundant comment about simulating completion calls --- tests/unit/frameworks/test_smolagents.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/unit/frameworks/test_smolagents.py b/tests/unit/frameworks/test_smolagents.py index 2e1e7e7d..af97f352 100644 --- a/tests/unit/frameworks/test_smolagents.py +++ b/tests/unit/frameworks/test_smolagents.py @@ -146,8 +146,6 @@ def test_create_client_creates_anyllm_instance(self) -> None: api_key="test-key", api_base="https://api.example.com", ) - # create_client is called by ApiModel.__init__, but we can call it again. - model.create_client() # Verify create was called with correct provider config. mock_anyllm_create.assert_called_with( @@ -181,8 +179,6 @@ def test_client_reused_across_multiple_calls(self) -> None: assert model.client is mock_client # Simulate multiple completion calls (the scenario that caused the bug). - # In the old implementation, each call would go through the functional - # API and potentially create new event loop resources. model.client.completion(messages=[{"role": "user", "content": "Hello"}]) model.client.completion(messages=[{"role": "user", "content": "World"}]) model.client.completion(messages=[{"role": "user", "content": "Test"}])