Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 53 additions & 14 deletions src/any_agent/frameworks/smolagents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -55,25 +56,55 @@ 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,
flatten_messages_as_text=flatten_messages_as_text,
**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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/any_agent/testing/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}

Expand Down
77 changes: 77 additions & 0 deletions tests/unit/frameworks/test_smolagents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -111,3 +112,79 @@ 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",
)

# 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).
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