diff --git a/README.rst b/README.rst index 1cb0d531e..278d2221e 100644 --- a/README.rst +++ b/README.rst @@ -12,4 +12,32 @@ A powerful, flexible Agent Based LLM tool for creating and managing networks of :target: https://travis-ci.org/cleverthis/cleveragents :alt: Travis-CI Build Status +API Key Configuration +--------------------- + +CleverAgents requires API keys for certain agents and tools (e.g., LLM providers, web search). Keys can be configured in two ways, with the agent-specific configuration taking precedence. + +1. **In Agent Configuration**: + Set the ``api_key`` field directly in the agent or tool definition within your YAML configuration file. If this key is present and not empty, it will always be used. + + .. code-block:: yaml + + agents: + - name: my_openai_agent + type: llm + config: + provider: openai + model: gpt-4 + api_key: "sk-your-key-from-config" + +2. **Via Environment Variables**: + If ``api_key`` is not set or is an empty string in the configuration, the application will look for a corresponding environment variable. + + - **OpenAI**: ``OPENAI_API_KEY`` + - **Anthropic**: ``ANTHROPIC_API_KEY`` + - **Google Gemini**: ``GOOGLE_GEMINI_API_KEY`` + - **Google Search Tool**: ``GOOGLE_SEARCH_API_KEY`` + +If a required API key is not found in either location, the application will raise a ``ConfigurationError``. + Overview diff --git a/src/cleveragents/agents/llm.py b/src/cleveragents/agents/llm.py index 44acd60cc..995da57c5 100644 --- a/src/cleveragents/agents/llm.py +++ b/src/cleveragents/agents/llm.py @@ -37,6 +37,12 @@ class LLMAgent(AgentWithMemory): generate responses. It supports different LLM providers and models, and handles prompt formatting and response processing. + API keys are resolved in the following order of precedence: + 1. A non-empty `api_key` in the agent's configuration. + 2. The corresponding environment variable (e.g., `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`). + + If a required key is not found, a `ConfigurationError` is raised. + Attributes: name (str): The name of the agent. config (Dict[str, Any]): The agent's configuration. @@ -72,9 +78,7 @@ class LLMAgent(AgentWithMemory): # Extract configuration self.provider = self.agent_config.get("provider", "openai").lower() self.model = self.agent_config.get("model", "gpt-3.5-turbo") - self.api_key = self.agent_config.get( - "api_key", os.environ.get(f"{self.provider.upper()}_API_KEY") - ) + self.api_key = self._get_api_key() # Always prioritise the configured system message, falling back to the default. self.system_message = self.agent_config.get( "system_message", DEFAULT_SYSTEM_MESSAGE @@ -82,14 +86,50 @@ class LLMAgent(AgentWithMemory): self.temperature = float(self.agent_config.get("temperature", 0.7)) self.max_tokens = int(self.agent_config.get("max_tokens", 1000)) - # Check that we have an API key - if not self.api_key: - raise AgentCreationError(f"No API key provided for {self.provider} LLM") - # Initialize memory for conversation history (system message is injected # fresh on every API call to avoid stale configuration issues). self.memory["messages"] = [] + def _get_api_key(self) -> str: + """ + Retrieves the API key from config or environment variables. + + The method follows this priority: + 1. A non-empty `api_key` in the agent's configuration. + 2. The corresponding environment variable (e.g., OPENAI_API_KEY). + + Raises: + ConfigurationError: If no API key can be found for a provider that requires one. + + Returns: + The API key string. + """ + config_api_key = self.agent_config.get("api_key") + if config_api_key and config_api_key.strip(): + return config_api_key.strip() + + provider_env_map = { + "openai": "OPENAI_API_KEY", + "anthropic": "ANTHROPIC_API_KEY", + "gemini": "GOOGLE_GEMINI_API_KEY", + } + env_var_name = provider_env_map.get(self.provider) + + if env_var_name: + env_api_key = os.environ.get(env_var_name) + if env_api_key: + return env_api_key + + if self.provider in provider_env_map: + raise ConfigurationError( + f"API key for provider '{self.provider}' not found. " + f"Please set it in the agent configuration or via the " + f"'{env_var_name}' environment variable." + ) + + # This case is for providers that do not require an API key. + return "" + async def process( self, message: str, context: Optional[Dict[str, Any]] = None ) -> str: diff --git a/src/cleveragents/agents/tool.py b/src/cleveragents/agents/tool.py index 0a70b7a58..abb66a64b 100644 --- a/src/cleveragents/agents/tool.py +++ b/src/cleveragents/agents/tool.py @@ -20,6 +20,7 @@ import aiohttp from cleveragents.agents.base import Agent from cleveragents.core.exceptions import AgentCreationError +from cleveragents.core.exceptions import ConfigurationError from cleveragents.core.exceptions import ExecutionError from cleveragents.templates.renderer import TemplateRenderer @@ -120,7 +121,8 @@ class WebSearchTool(Tool): A tool for searching the web using Google Custom Search. This tool performs web searches and returns the results. It requires a Google - API Key and a Programmable Search Engine ID (cx). + API Key and a Programmable Search Engine ID (cx). The API key can be provided + in the configuration or via the GOOGLE_SEARCH_API_KEY environment variable. Attributes: api_key (str): The API key for the search engine. @@ -150,23 +152,52 @@ class WebSearchTool(Tool): f"Search engine '{self.search_engine}' is not supported. Only 'google' is available." ) - # API key can be in config or from environment variable GOOGLE_API_KEY - self.api_key = config.get("api_key") or os.environ.get("GOOGLE_API_KEY") + self.api_key = self._get_api_key() # CX (Custom Search Engine ID) can be in config or from environment variable GOOGLE_CX self.cx = config.get("cx") or os.environ.get("GOOGLE_CX") - if not self.api_key: - raise AgentCreationError( - "Google API Key not found. Please provide it in the tool config " - "or set the GOOGLE_API_KEY environment variable." - ) - if not self.cx: raise AgentCreationError( "Google Custom Search Engine ID (cx) not found. Please provide it " "in the tool config or set the GOOGLE_CX environment variable." ) + def _get_api_key(self) -> str: + """ + Retrieves the API key from config or environment variables. + + The method follows this priority: + 1. A non-empty `api_key` in the tool's configuration. + 2. The corresponding environment variable (e.g., GOOGLE_SEARCH_API_KEY). + + Raises: + ConfigurationError: If no API key can be found for a search engine that requires one. + + Returns: + The API key string. + """ + config_api_key = self.config.get("api_key") + if config_api_key and config_api_key.strip(): + return config_api_key.strip() + + engine_env_map = { + "google": "GOOGLE_SEARCH_API_KEY", + } + env_var_name = engine_env_map.get(self.search_engine) + + if env_var_name: + env_api_key = os.environ.get(env_var_name) + if env_api_key: + return env_api_key + + if self.search_engine in engine_env_map: + raise ConfigurationError( + f"API key for search engine '{self.search_engine}' not found. " + f"Please set it in the tool configuration or via the " + f"'{env_var_name}' environment variable." + ) + return "" + async def execute( self, input_data: str, context: Optional[Dict[str, Any]] = None ) -> str: diff --git a/src/examples/advanced_agent_network.yaml b/src/examples/advanced_agent_network.yaml index 866cd674c..805308cc2 100644 --- a/src/examples/advanced_agent_network.yaml +++ b/src/examples/advanced_agent_network.yaml @@ -29,7 +29,6 @@ agents: config: model: gpt-4 provider: openai - api_key: ${OPENAI_API_KEY} temperature: 0.3 prompt_template: technical_analysis_prompt @@ -38,7 +37,6 @@ agents: config: model: gpt-3.5-turbo provider: openai - api_key: ${OPENAI_API_KEY} temperature: 0.4 prompt_template: summary_prompt diff --git a/src/examples/basic_agent_network.yaml b/src/examples/basic_agent_network.yaml index ea57c9ffe..e021f5421 100644 --- a/src/examples/basic_agent_network.yaml +++ b/src/examples/basic_agent_network.yaml @@ -20,7 +20,6 @@ agents: config: model: claude-3-opus provider: anthropic - api_key: ${ANTHROPIC_API_KEY} temperature: 0.2 prompt_template: analysis_prompt diff --git a/src/examples/composite_agent_route_example.yaml b/src/examples/composite_agent_route_example.yaml index 99b97ab1a..ac6414bfa 100644 --- a/src/examples/composite_agent_route_example.yaml +++ b/src/examples/composite_agent_route_example.yaml @@ -10,7 +10,6 @@ agents: config: model: gpt-4 provider: openai - api_key: ${OPENAI_API_KEY} temperature: 0.7 prompt_template: research_prompt @@ -20,7 +19,6 @@ agents: config: model: claude-3-opus-latest provider: anthropic - api_key: ${ANTHROPIC_API_KEY} temperature: 0.2 prompt_template: financial_analysis_prompt @@ -29,7 +27,6 @@ agents: config: model: gpt-4 provider: openai - api_key: ${OPENAI_API_KEY} temperature: 0.3 prompt_template: technical_analysis_prompt @@ -38,7 +35,6 @@ agents: config: model: gpt-3.5-turbo provider: openai - api_key: ${OPENAI_API_KEY} temperature: 0.4 prompt_template: summary_prompt diff --git a/src/examples/multi_agent_workflow.yaml b/src/examples/multi_agent_workflow.yaml index 66a4c520b..0939309b1 100644 --- a/src/examples/multi_agent_workflow.yaml +++ b/src/examples/multi_agent_workflow.yaml @@ -29,7 +29,6 @@ agents: config: provider: openai model: gpt-3.5-turbo - api_key: "${OPENAI_API_KEY}" # Uses environment variable for security system_message: | You are a classification expert. Your task is to analyze the user's prompt and determine its primary intent. Respond with ONLY ONE of the following keywords based on the prompt: @@ -42,7 +41,6 @@ agents: config: provider: openai model: gpt-3.5-turbo - api_key: "${OPENAI_API_KEY}" # Uses environment variable for security system_message: | The user is providing a prompt that requires a calculation to be performed. Respond with ONLY the calculation that needs to be performed without any words or texts present. Make sure the response uses @@ -65,7 +63,6 @@ agents: config: provider: openai model: gpt-4o-mini - api_key: "${OPENAI_API_KEY}" system_message: | You are a response synthesizer. Your job is to take the raw output from a tool which performs the the mathematical calculation present in a users response, along with the users original prompt, and @@ -88,7 +85,6 @@ agents: config: provider: openai model: gpt-4o-mini - api_key: "${OPENAI_API_KEY}" system_message: | You are friendly and helpful assistant. Your task is to analyze the user's prompt and determine if you have enough knowledge to answer the question like an expert on the topic. @@ -112,7 +108,6 @@ agents: config: provider: openai model: gpt-4o-mini - api_key: "${OPENAI_API_KEY}" system_message: | You are a response synthesizer. Your job is to take the raw output from a web search, along with the original prompt a user provided, which the web search is relevant to, and provide an appropriate @@ -132,7 +127,6 @@ agents: config: provider: openai model: gpt-4o-mini - api_key: "${OPENAI_API_KEY}" system_message: "You are a friendly and helpful assistant. Provide clear and concise answers to the user's questions." temperature: 0.5 diff --git a/tests/features/api_key_handling.feature b/tests/features/api_key_handling.feature new file mode 100644 index 000000000..f91f0f711 --- /dev/null +++ b/tests/features/api_key_handling.feature @@ -0,0 +1,35 @@ +Feature: API Key Handling for Agents and Tools + + Scenario: LLM Agent uses API key from config when present + Given an LLM agent config for provider "openai" with api_key "config-key" + And the environment variable "OPENAI_API_KEY" is set to "env-key" + When I initialize the LLM agent with config + Then the agent's API key should be "config-key" + + Scenario: LLM Agent falls back to environment variable when config key is missing + Given an LLM agent config for provider "openai" with api_key "null" + And the environment variable "OPENAI_API_KEY" is set to "env-key" + When I initialize the LLM agent with config + Then the agent's API key should be "env-key" + + Scenario: LLM Agent falls back to environment variable when config key is empty + Given an LLM agent config for provider "openai" with api_key "empty" + And the environment variable "OPENAI_API_KEY" is set to "env-key" + When I initialize the LLM agent with config + Then the agent's API key should be "env-key" + + Scenario: LLM Agent raises error if no API key is found + Given an LLM agent config for provider "openai" with api_key "null" + And the environment variable "OPENAI_API_KEY" is unset + When I initialize the LLM agent with config + Then a ConfigurationError should be raised containing "OPENAI_API_KEY" + + Scenario: WebSearchTool uses API key from environment variable + Given the environment variable "GOOGLE_SEARCH_API_KEY" is set to "env-key-for-search" + When I initialize a WebSearchTool with a missing API key + Then the tool's API key should be "env-key-for-search" + + Scenario: WebSearchTool raises error if no API key is found + Given the environment variable "GOOGLE_SEARCH_API_KEY" is unset + When I try to initialize a WebSearchTool with a missing API key + Then a ConfigurationError should be raised containing "GOOGLE_SEARCH_API_KEY" diff --git a/tests/features/steps/llm_agent_detailed_steps.py b/tests/features/steps/llm_agent_detailed_steps.py index a6832daaf..1775c9bed 100644 --- a/tests/features/steps/llm_agent_detailed_steps.py +++ b/tests/features/steps/llm_agent_detailed_steps.py @@ -237,3 +237,77 @@ def step_impl(context): assert context.processed_response.startswith( "Formatted response:" ), f"Response was not formatted correctly: {context.processed_response}" + + +import os +from unittest.mock import patch + +from behave import given +from behave import then +from behave import when + +from cleveragents.agents.llm import LLMAgent +from cleveragents.core.exceptions import ConfigurationError +from cleveragents.templates.renderer import TemplateRenderer + + +@given('the environment variable "{name}" is set to "{value}"') +def step_env_set(context, name, value): + if not hasattr(context, "env_patchers"): + context.env_patchers = [] + patcher = patch.dict(os.environ, {name: value}) + patcher.start() + context.env_patchers.append(patcher) + + +@given('the environment variable "{name}" is unset') +def step_env_unset(context, name): + if not hasattr(context, "env_patchers"): + context.env_patchers = [] + patcher = patch.dict(os.environ) + patcher.start() + if name in os.environ: + del os.environ[name] + context.env_patchers.append(patcher) + + +@given('an LLM agent config for provider "{provider}" with api_key "{api_key}"') +def step_llm_config(context, provider, api_key): + context.template_renderer = TemplateRenderer() + context.llm_config = { + "provider": provider, + "model": "test-model", + } + if api_key == "null": + pass # Don't add the key to the config dict + elif api_key == "empty": + context.llm_config["api_key"] = "" + else: + context.llm_config["api_key"] = api_key + + +@when("I initialize the LLM agent with config") +def step_initialize_llm(context): + context.error = None + try: + context.agent = LLMAgent( + "test_agent", {"config": context.llm_config}, context.template_renderer + ) + except Exception as e: + context.error = e + + +@then('the agent\'s API key should be "{api_key}"') +def step_check_api_key(context, api_key): + assert not context.error, f"Agent initialization failed with: {context.error}" + assert hasattr(context, "agent"), "Agent was not initialized." + assert context.agent.api_key == api_key + + +@then('a ConfigurationError should be raised containing "{text}"') +def step_check_configuration_error(context, text): + assert context.error, "Expected a ConfigurationError, but no exception was raised." + assert isinstance( + context.error, ConfigurationError + ), f"Expected ConfigurationError, but got {type(context.error).__name__}." + assert text in str(context.error) diff --git a/tests/features/steps/tool_agent_steps.py b/tests/features/steps/tool_agent_steps.py index cf3e50142..de2f938d5 100644 --- a/tests/features/steps/tool_agent_steps.py +++ b/tests/features/steps/tool_agent_steps.py @@ -1,6 +1,5 @@ import asyncio from unittest.mock import MagicMock -from unittest.mock import patch from behave import given from behave import then @@ -89,3 +88,25 @@ def step_impl(context): assert context.results["format1"] == "2" assert "Web search results for 'CleverAgents'" in context.results["format2"] assert "Web search results for" in context.results["format3"] + + +from cleveragents.core.exceptions import ConfigurationError + + +@when("I initialize a WebSearchTool with a missing API key") +@when("I try to initialize a WebSearchTool with a missing API key") +def step_initialize_web_search_missing_api(context): + context.error = None + try: + # Config is missing the 'api_key' + config = {"search_engine": "google", "cx": "test-cx"} + context.tool = WebSearchTool(config) + except Exception as e: + context.error = e + + +@then('the tool\'s API key should be "{api_key}"') +def step_check_tool_api_key(context, api_key): + assert not context.error, f"Tool initialization failed with: {context.error}" + assert hasattr(context, "tool") + assert context.tool.api_key == api_key diff --git a/tests/features/steps/tool_agent_steps.py (Rename final step definition) b/tests/features/steps/tool_agent_steps.py (Rename final step definition) new file mode 100644 index 000000000..e69de29bb diff --git a/tests/features/support/env.py b/tests/features/support/env.py index 980258871..f90eb5e40 100644 --- a/tests/features/support/env.py +++ b/tests/features/support/env.py @@ -56,3 +56,8 @@ def after_scenario(context, scenario): if hasattr(context, "error"): print(f"Error: {context.error}") print("===============================\n") + + if hasattr(context, "env_patchers"): + for patcher in reversed(context.env_patchers): + patcher.stop() + delattr(context, "env_patchers")