diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 00000000..4de60316 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,127 @@ +# Authentication + +Microbots supports two authentication methods for LLM providers: + +## 1. API Key Authentication (Default) + +Set the API key as an environment variable. This is the default and requires no additional setup. + +```bash +# For Azure OpenAI +export OPEN_AI_KEY="your-api-key" +export OPEN_AI_END_POINT="https://your-endpoint.openai.azure.com" +export OPEN_AI_API_VERSION="2024-02-01" +export OPEN_AI_DEPLOYMENT_NAME="your-deployment" + +# For Anthropic +export ANTHROPIC_API_KEY="your-api-key" +export ANTHROPIC_END_POINT="https://your-endpoint" +export ANTHROPIC_DEPLOYMENT_NAME="your-deployment" +``` + +## 2. Azure AD Token Authentication + +For environments that require Azure AD authentication (no static API keys), Microbots can automatically obtain and refresh tokens using `azure-identity`. + +`azure-identity` is a **default dependency** — no extra install step is needed. + +### Option A: Environment Variable Opt-In + +Set `AZURE_AUTH_METHOD=azure_ad` and configure your credentials. Microbots will use `DefaultAzureCredential`, which automatically tries the following sources in order: environment variables, workload identity, managed identity, Azure CLI, and more. + +**Service Principal:** +```bash +export AZURE_AUTH_METHOD=azure_ad +export AZURE_CLIENT_ID="your-client-id" +export AZURE_TENANT_ID="your-tenant-id" +export AZURE_CLIENT_SECRET="your-client-secret" +``` + +**Managed Identity** (on Azure VMs, Container Apps, App Service, etc.): +```bash +export AZURE_AUTH_METHOD=azure_ad +# No other env vars needed — managed identity is detected automatically +``` + +**Azure CLI** (local development): +```bash +az login +export AZURE_AUTH_METHOD=azure_ad +``` + +Also set the relevant LLM endpoint env vars (no API key required): + +```bash +# Azure OpenAI +export OPEN_AI_END_POINT="https://your-endpoint.openai.azure.com" +export OPEN_AI_API_VERSION="2024-02-01" +export OPEN_AI_DEPLOYMENT_NAME="your-deployment" + +# Anthropic Foundry +export ANTHROPIC_END_POINT="https://your-foundry-endpoint" +export ANTHROPIC_DEPLOYMENT_NAME="your-deployment" +``` + +> **Note:** `AZURE_AUTH_METHOD=azure_ad` only auto-creates a token provider for the `azure-openai` provider (using the `https://cognitiveservices.azure.com/.default` scope). For `anthropic` (Azure AI Foundry), the required scope is different and cannot be inferred automatically. You must pass `token_provider` explicitly — see **Option B** below. + +### Option B: Pass a Token Provider Programmatically + +Pass any `Callable[[], str]` as `token_provider`. The recommended approach uses `get_bearer_token_provider` from `azure-identity`: + +```python +from azure.identity import DefaultAzureCredential, get_bearer_token_provider +from microbots.MicroBot import MicroBot + +credential = DefaultAzureCredential() +token_provider = get_bearer_token_provider( + credential, "https://cognitiveservices.azure.com/.default" +) + +bot = MicroBot( + model="azure-openai/your-deployment", + token_provider=token_provider, +) +``` + +You can substitute any `azure-identity` credential class for `DefaultAzureCredential`: + +```python +from azure.identity import ClientSecretCredential, get_bearer_token_provider + +credential = ClientSecretCredential( + tenant_id="your-tenant-id", + client_id="your-client-id", + client_secret="your-client-secret", +) +token_provider = get_bearer_token_provider( + credential, "https://cognitiveservices.azure.com/.default" +) + +bot = MicroBot( + model="azure-openai/your-deployment", + token_provider=token_provider, +) +``` + +### How Token Refresh Works + +- `get_bearer_token_provider` returns a `Callable[[], str]` backed by `BearerTokenCredentialPolicy`. +- The token is cached and **proactively refreshed** before expiry — no manual refresh needed. +- Both `AzureOpenAI` and `AnthropicFoundry` SDKs call the provider **before every request**, so the token is always fresh. +- Tasks are **never interrupted** by token expiration. + +### How the Provider Is Selected + +| `token_provider` present | LLM provider | SDK client used | +|---|---|---| +| Yes | `azure-openai` | `AzureOpenAI(azure_ad_token_provider=...)` | +| No | `azure-openai` | `OpenAI(api_key=...)` | +| Yes | `anthropic` | `AnthropicFoundry(azure_ad_token_provider=...)` | +| No | `anthropic` | `Anthropic(api_key=...)` | + +`OllamaLocal` (local models) does not use token authentication. + +### Notes + +- A `ValueError` is raised at bot creation time if neither an API key nor a token provider is configured. This surfaces misconfigurations early rather than failing on the first API call. +- The browser tool runs inside Docker. When `AZURE_AUTH_METHOD=azure_ad` is set (or a `token_provider` is passed to `BrowsingBot`), `BrowsingBot.run()` calls the token provider, gets a fresh token, and injects it as `AZURE_OPENAI_AD_TOKEN` into the container. `browser.py` inside Docker reads this env var and passes it as `azure_ad_token` to `ChatAzureOpenAI`. The token is valid for ~1 hour, which is sufficient for typical browser tasks. `AZURE_OPENAI_API_KEY` is not required when using Azure AD auth. diff --git a/requirements.txt b/requirements.txt index 4bf03b51..d2375316 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ aiohttp==3.12.15 aiosignal==1.4.0 annotated-types==0.7.0 anthropic==0.75.0 +azure-identity>=1.15.0 anyio==4.10.0 attrs==25.3.0 bashlex==0.18 diff --git a/src/microbots/MicroBot.py b/src/microbots/MicroBot.py index 3ace5a4e..0284430e 100644 --- a/src/microbots/MicroBot.py +++ b/src/microbots/MicroBot.py @@ -1,5 +1,6 @@ from collections.abc import Iterable import json +import os from pprint import pformat import re import time @@ -12,6 +13,7 @@ from microbots.environment.local_docker.LocalDockerEnvironment import ( LocalDockerEnvironment, ) +from azure.identity import DefaultAzureCredential, get_bearer_token_provider from microbots.llm.anthropic_api import AnthropicApi from microbots.llm.openai_api import OpenAIApi from microbots.llm.ollama_local import OllamaLocal @@ -100,8 +102,9 @@ def __init__( bot_type: BotType = BotType.CUSTOM_BOT, system_prompt: Optional[str] = None, environment: Optional[any] = None, - additional_tools: Optional[list[ToolAbstract]] = [], + additional_tools: Optional[list[ToolAbstract]] = None, folder_to_mount: Optional[Mount] = None, + token_provider: Optional[any] = None, ): """ Init function for MicroBot class. @@ -119,7 +122,7 @@ def __init__( LocalDockerEnvironment will be created. additional_tools :Optional[list[ToolAbstract]] A list of additional tools to install in the bot's environment. - Defaults to []. + Defaults to None (treated as an empty list). folder_to_mount :Optional[Mount] A folder to mount into the bot's environment. The bot will be given access to this folder based on the specified permissions. This will @@ -150,7 +153,7 @@ def __init__( self.model = model self.bot_type = bot_type self.environment = environment - self.additional_tools = additional_tools + self.additional_tools = additional_tools or [] # TODO: Replace iteration_count and max_iterations with cost management. # Iteration count represents overall LLM interactions including interactions @@ -163,6 +166,20 @@ def __init__( self.model_provider = model.split("/")[0] self.deployment_name = model.split("/")[1] + # Only auto-create token provider from env for providers that support Azure AD tokens. + if token_provider is not None: + self.token_provider = token_provider + elif ( + os.getenv("AZURE_AUTH_METHOD", "").strip().lower() == "azure_ad" + and self.model_provider == ModelProvider.OPENAI + ): + credential = DefaultAzureCredential() + self.token_provider = get_bearer_token_provider( + credential, "https://cognitiveservices.azure.com/.default" + ) + else: + self.token_provider = None + if not self.environment: self._create_environment(self.folder_to_mount) @@ -327,7 +344,8 @@ def _create_llm(self): if self.model_provider == ModelProvider.OPENAI: self.llm = OpenAIApi( - system_prompt=system_prompt_with_tools, deployment_name=self.deployment_name + system_prompt=system_prompt_with_tools, deployment_name=self.deployment_name, + token_provider=self.token_provider, ) elif self.model_provider == ModelProvider.OLLAMA_LOCAL: self.llm = OllamaLocal( @@ -335,7 +353,8 @@ def _create_llm(self): ) elif self.model_provider == ModelProvider.ANTHROPIC: self.llm = AnthropicApi( - system_prompt=system_prompt_with_tools, deployment_name=self.deployment_name + system_prompt=system_prompt_with_tools, deployment_name=self.deployment_name, + token_provider=self.token_provider, ) # No Else case required as model provider is already validated using _validate_model_and_provider diff --git a/src/microbots/bot/BrowsingBot.py b/src/microbots/bot/BrowsingBot.py index c7263e0d..789c1b6b 100644 --- a/src/microbots/bot/BrowsingBot.py +++ b/src/microbots/bot/BrowsingBot.py @@ -1,3 +1,4 @@ +import shlex from typing import Optional from microbots.MicroBot import BotType, MicroBot, BotRunResult @@ -15,7 +16,8 @@ def __init__( self, model: str, environment: Optional[Environment] = None, - additional_tools: Optional[list[ToolAbstract]] = [], + additional_tools: Optional[list[ToolAbstract]] = None, + token_provider: Optional[any] = None, ): # validate init values before assigning bot_type = BotType.BROWSING_BOT @@ -28,15 +30,22 @@ def __init__( model=model, system_prompt=system_prompt, environment=environment, - additional_tools=additional_tools + [BROWSER_USE_TOOL], + additional_tools=(additional_tools or []) + [BROWSER_USE_TOOL], + token_provider=token_provider, ) - def run(self, task, max_iterations=20, timeout_in_seconds=200) -> BotRunResult: + def run(self, task, timeout_in_seconds=200) -> BotRunResult: for tool in self.additional_tools: tool.setup_tool(self.environment) + # If an Azure AD token provider is configured, get a fresh token and inject it into the + # container so that browser.py (running inside Docker) can use it for ChatAzureOpenAI. + if self.token_provider is not None: + token = self.token_provider() + self.environment.execute(f'export AZURE_OPENAI_AD_TOKEN={shlex.quote(token)}') + # browser-use will run inside the docker. So, single command to env should be sufficient - browser_output = self.environment.execute(f"browser '{task}'", timeout=timeout_in_seconds) + browser_output = self.environment.execute(f"browser {shlex.quote(task)}", timeout=timeout_in_seconds) if browser_output.return_code != 0: return BotRunResult( status=False, @@ -45,9 +54,7 @@ def run(self, task, max_iterations=20, timeout_in_seconds=200) -> BotRunResult: ) browser_stdout = browser_output.stdout - # print("Browser stdout:", browser_stdout) - # final_result = browser_stdout.split("Final result:")[-1].strip() if "Final result:" in browser_stdout else browser_stdout.strip() - final_result = browser_stdout["Final result:"] if "Final result:" in browser_stdout else browser_stdout + final_result = browser_stdout.split("Final result:")[-1].strip() if "Final result:" in browser_stdout else browser_stdout.strip() return BotRunResult( status=browser_output.return_code == 0, diff --git a/src/microbots/bot/LogAnalysisBot.py b/src/microbots/bot/LogAnalysisBot.py index e5393f3f..b2c682b1 100644 --- a/src/microbots/bot/LogAnalysisBot.py +++ b/src/microbots/bot/LogAnalysisBot.py @@ -17,7 +17,8 @@ def __init__( model: str, folder_to_mount: str, environment: Optional[any] = None, - additional_tools: Optional[list[ToolAbstract]] = [], + additional_tools: Optional[list[ToolAbstract]] = None, + token_provider: Optional[any] = None, ): # validate init values before assigning bot_type = BotType.LOG_ANALYSIS_BOT @@ -42,8 +43,9 @@ def __init__( bot_type=bot_type, system_prompt=system_prompt, environment=environment, - additional_tools=additional_tools, + additional_tools=additional_tools or [], folder_to_mount=folder_mount_info, + token_provider=token_provider, ) def run(self, file_name: str, max_iterations: int = 20, timeout_in_seconds: int = 300) -> any: diff --git a/src/microbots/bot/ReadingBot.py b/src/microbots/bot/ReadingBot.py index a1dd8ef8..f29d77c5 100644 --- a/src/microbots/bot/ReadingBot.py +++ b/src/microbots/bot/ReadingBot.py @@ -14,7 +14,8 @@ def __init__( model: str, folder_to_mount: str, environment: Optional[any] = None, - additional_tools: Optional[list[ToolAbstract]] = [], + additional_tools: Optional[list[ToolAbstract]] = None, + token_provider: Optional[any] = None, ): # validate init values before assigning bot_type = BotType.READING_BOT @@ -39,6 +40,7 @@ def __init__( bot_type=bot_type, system_prompt=system_prompt, environment=environment, - additional_tools=additional_tools, + additional_tools=additional_tools or [], folder_to_mount=folder_mount_info, + token_provider=token_provider, ) diff --git a/src/microbots/bot/WritingBot.py b/src/microbots/bot/WritingBot.py index b1882ca5..8dfb75b0 100644 --- a/src/microbots/bot/WritingBot.py +++ b/src/microbots/bot/WritingBot.py @@ -14,7 +14,8 @@ def __init__( model: str, folder_to_mount: str, environment: Optional[any] = None, - additional_tools: Optional[list[ToolAbstract]] = [], + additional_tools: Optional[list[ToolAbstract]] = None, + token_provider: Optional[any] = None, ): # validate init values before assigning bot_type = BotType.WRITING_BOT @@ -44,6 +45,7 @@ def __init__( bot_type=bot_type, system_prompt=system_prompt, environment=environment, - additional_tools=additional_tools, + additional_tools=additional_tools or [], folder_to_mount=folder_mount_info, + token_provider=token_provider, ) diff --git a/src/microbots/llm/anthropic_api.py b/src/microbots/llm/anthropic_api.py index f40118ae..54376787 100644 --- a/src/microbots/llm/anthropic_api.py +++ b/src/microbots/llm/anthropic_api.py @@ -1,10 +1,13 @@ import json import os +import re +from collections.abc import Callable from dataclasses import asdict from logging import getLogger from dotenv import load_dotenv from anthropic import Anthropic +from anthropic.lib.foundry import AnthropicFoundry from microbots.llm.llm import LLMAskResponse, LLMInterface logger = getLogger(__name__) @@ -18,11 +21,27 @@ class AnthropicApi(LLMInterface): - def __init__(self, system_prompt, deployment_name=deployment_name, max_retries=3): - self.ai_client = Anthropic( - api_key=api_key, - base_url=endpoint - ) + def __init__(self, system_prompt, deployment_name=deployment_name, max_retries=3, + token_provider: Callable[[], str] | None = None): + self.token_provider = token_provider + + if not token_provider and not api_key: + raise ValueError( + "No authentication configured for Anthropic. Either set the ANTHROPIC_API_KEY " + "environment variable or provide a token_provider (e.g. AzureTokenProvider)." + ) + + if token_provider: + # Azure AD auth — use AnthropicFoundry with ANTHROPIC_END_POINT as base_url + self.ai_client = AnthropicFoundry( + azure_ad_token_provider=token_provider, + base_url=endpoint, + ) + else: + self.ai_client = Anthropic( + api_key=api_key, + base_url=endpoint + ) self.deployment_name = deployment_name self.system_prompt = system_prompt self.messages = [] @@ -50,7 +69,6 @@ def ask(self, message) -> LLMAskResponse: logger.debug("Raw Anthropic response (first 500 chars): %s", response_text[:500]) # Try to extract JSON if wrapped in markdown code blocks - import re json_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', response_text, re.DOTALL) if json_match: response_text = json_match.group(1) diff --git a/src/microbots/llm/openai_api.py b/src/microbots/llm/openai_api.py index 6c85f3d2..b2c50845 100644 --- a/src/microbots/llm/openai_api.py +++ b/src/microbots/llm/openai_api.py @@ -1,22 +1,45 @@ import json import os +from collections.abc import Callable from dataclasses import asdict from dotenv import load_dotenv -from openai import OpenAI +from openai import AzureOpenAI, OpenAI from microbots.llm.llm import LLMAskResponse, LLMInterface load_dotenv() endpoint = os.getenv("OPEN_AI_END_POINT") +api_version = os.getenv("OPEN_AI_API_VERSION") deployment_name = os.getenv("OPEN_AI_DEPLOYMENT_NAME") -api_key = os.getenv("OPEN_AI_KEY") # use the api_key +api_key = os.getenv("OPEN_AI_KEY") class OpenAIApi(LLMInterface): - def __init__(self, system_prompt, deployment_name=deployment_name, max_retries=3): - self.ai_client = OpenAI(base_url=f"{endpoint}", api_key=api_key) + def __init__(self, system_prompt, deployment_name=deployment_name, max_retries=3, + token_provider: Callable[[], str] | None = None): + self.token_provider = token_provider + + if not token_provider and not api_key: + raise ValueError( + "No authentication configured for OpenAI. Either set the OPEN_AI_KEY " + "environment variable or provide a token_provider (e.g. AzureTokenProvider)." + ) + + if token_provider: + # Azure users with AD token — use AzureOpenAI which calls token_provider natively per request + self.ai_client = AzureOpenAI( + azure_endpoint=endpoint, + azure_ad_token_provider=token_provider, + api_version=api_version, + ) + else: + # Non-Azure users with a plain API key + self.ai_client = OpenAI( + base_url=endpoint, + api_key=api_key, + ) self.deployment_name = deployment_name self.system_prompt = system_prompt self.messages = [{"role": "system", "content": system_prompt}] diff --git a/src/microbots/tools/tool_definitions/browser-use.yaml b/src/microbots/tools/tool_definitions/browser-use.yaml index 9e1b0550..04130906 100644 --- a/src/microbots/tools/tool_definitions/browser-use.yaml +++ b/src/microbots/tools/tool_definitions/browser-use.yaml @@ -21,11 +21,12 @@ setup_commands: - cat /sbin/browser env_variables: - - AZURE_OPENAI_API_KEY + - AZURE_OPENAI_API_KEY # Optional when using Azure AD auth (AZURE_AUTH_METHOD=azure_ad) - AZURE_OPENAI_ENDPOINT - AZURE_OPENAI_API_VERSION - BROWSER_USE_LLM_MODEL - BROWSER_USE_LLM_TEMPERATURE + # AZURE_OPENAI_AD_TOKEN is injected at runtime by BrowsingBot when token_provider is set — not listed here uninstall_commands: - playwright uninstall chromium diff --git a/src/microbots/tools/tool_definitions/browser-use/browser.py b/src/microbots/tools/tool_definitions/browser-use/browser.py index e6410ced..73fcb822 100644 --- a/src/microbots/tools/tool_definitions/browser-use/browser.py +++ b/src/microbots/tools/tool_definitions/browser-use/browser.py @@ -23,6 +23,17 @@ sys.exit(1) +def _build_llm(): + ad_token = os.getenv("AZURE_OPENAI_AD_TOKEN") + if ad_token: + kwargs = {"azure_ad_token": ad_token} + else: + kwargs = {} + if TEMP is not None: + kwargs["temperature"] = TEMP + return ChatAzureOpenAI(model=MODEL, **kwargs) + + async def main(args: list[str]) -> int: if len(args) > 1: print("browse allows only one query at a time.") @@ -48,7 +59,7 @@ async def main(args: list[str]) -> int: agent = Agent( task=what_to_browse, browser=browser, - llm=ChatAzureOpenAI(model=MODEL, temperature=TEMP) if TEMP else ChatAzureOpenAI(model=MODEL), + llm=_build_llm(), use_vision=False, ) history: AgentHistoryList = await agent.run() diff --git a/src/microbots/tools/tool_definitions/microbot_sub_agent.py b/src/microbots/tools/tool_definitions/microbot_sub_agent.py index 548b0f32..96ef552a 100644 --- a/src/microbots/tools/tool_definitions/microbot_sub_agent.py +++ b/src/microbots/tools/tool_definitions/microbot_sub_agent.py @@ -109,6 +109,7 @@ def invoke(self, command: str, parent_bot: "MicroBot") -> CmdReturn: model=parent_bot.model, system_prompt=system_prompt_common, environment=parent_bot.environment, + token_provider=parent_bot.token_provider, ) result: BotRunResult = sub_bot.run( diff --git a/test/bot/test_browsing_bot.py b/test/bot/test_browsing_bot.py index a81e50cc..8e673ec4 100644 --- a/test/bot/test_browsing_bot.py +++ b/test/bot/test_browsing_bot.py @@ -2,6 +2,7 @@ import os import sys import pytest +from unittest.mock import patch, MagicMock # Setup logging for tests logger = logging.getLogger(__name__) @@ -13,6 +14,121 @@ # Add src to path for imports sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src/"))) from microbots import BrowsingBot, BotRunResult +from microbots.MicroBot import MicroBot +from microbots.environment.Environment import CmdReturn + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_browsing_bot(model="azure-openai/test-deploy", token_provider=None): + """Create a BrowsingBot with all heavy dependencies mocked out.""" + mock_env = MagicMock() + + with patch.object(MicroBot, "_create_environment"), \ + patch.object(MicroBot, "_create_llm"): + with patch("microbots.bot.BrowsingBot.BROWSER_USE_TOOL") as mock_tool: + mock_tool.install_tool = MagicMock() + mock_tool.verify_tool_installation = MagicMock() + mock_tool.setup_tool = MagicMock() + mock_tool.usage_instructions_to_llm = None + bot = BrowsingBot(model=model, environment=mock_env, token_provider=token_provider) + + bot.environment = mock_env + return bot + + +# --------------------------------------------------------------------------- +# Unit tests +# --------------------------------------------------------------------------- + +@pytest.mark.unit +class TestBrowsingBotRun: + """Unit tests for BrowsingBot.run() — no Docker or LLM required.""" + + def test_token_provider_injects_token_into_environment(self): + """When token_provider is set, a fresh token is fetched and exported into the container.""" + token_provider = MagicMock(return_value="my.jwt.token") + bot = _make_browsing_bot(token_provider=token_provider) + + bot.environment.execute.return_value = CmdReturn( + stdout="Final result: done", stderr="", return_code=0 + ) + + bot.run("some task") + + # First execute call should be the export + export_call = bot.environment.execute.call_args_list[0] + assert "AZURE_OPENAI_AD_TOKEN=my.jwt.token" in export_call.args[0] + + def test_token_not_injected_when_provider_is_none(self): + """When token_provider is None, no export command is issued.""" + bot = _make_browsing_bot(token_provider=None) + + bot.environment.execute.return_value = CmdReturn( + stdout="done", stderr="", return_code=0 + ) + + bot.run("some task") + + # Only one execute call: the browser command itself + assert bot.environment.execute.call_count == 1 + + def test_task_with_single_quote_is_quoted(self): + """Tasks containing single quotes must be shell-safe.""" + bot = _make_browsing_bot() + + bot.environment.execute.return_value = CmdReturn( + stdout="done", stderr="", return_code=0 + ) + + bot.run("What's the capital of France?") + + browser_call = bot.environment.execute.call_args_list[-1] + cmd = browser_call.args[0] + # shlex.quote wraps in single quotes and escapes internal ones + assert "What" in cmd + assert "browser" in cmd + + def test_browser_failure_returns_error_result(self): + """A non-zero return code from the browser command is surfaced as a failed BotRunResult.""" + bot = _make_browsing_bot() + + bot.environment.execute.return_value = CmdReturn( + stdout="", stderr="browser crashed", return_code=1 + ) + + result = bot.run("find something") + + assert result.status is False + assert result.result is None + assert "browser crashed" in result.error + + def test_final_result_extracted_from_stdout(self): + """'Final result:' prefix is stripped from browser output.""" + bot = _make_browsing_bot() + + bot.environment.execute.return_value = CmdReturn( + stdout="some preamble\nFinal result: Paris", stderr="", return_code=0 + ) + + result = bot.run("capital of France") + + assert result.status is True + assert result.result == "Paris" + + def test_stdout_without_final_result_marker_returned_as_is(self): + """When the output has no 'Final result:' marker the full stripped stdout is returned.""" + bot = _make_browsing_bot() + + bot.environment.execute.return_value = CmdReturn( + stdout=" raw output ", stderr="", return_code=0 + ) + + result = bot.run("some task") + + assert result.result == "raw output" @pytest.mark.integration @pytest.mark.docker diff --git a/test/bot/test_log_analysis_bot.py b/test/bot/test_log_analysis_bot.py index 104c021d..2823e45b 100644 --- a/test/bot/test_log_analysis_bot.py +++ b/test/bot/test_log_analysis_bot.py @@ -9,6 +9,8 @@ import sys import pytest +from unittest.mock import patch, MagicMock + # Add src directory to path to import from local source sys.path.insert( 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src")) @@ -19,6 +21,83 @@ logger = logging.getLogger(__name__) from microbots import LogAnalysisBot, BotRunResult +from microbots.MicroBot import MicroBot +from microbots.tools.tool import ToolAbstract + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_log_bot(model="azure-openai/test-deploy", additional_tools=None, token_provider=None): + """Create a LogAnalysisBot with Docker and LLM mocked out.""" + mock_env = MagicMock() + + with patch.object(MicroBot, "_create_environment"), \ + patch.object(MicroBot, "_create_llm"): + bot = LogAnalysisBot( + model=model, + folder_to_mount="/tmp/fake-repo", + environment=mock_env, + additional_tools=additional_tools, + token_provider=token_provider, + ) + + bot.environment = mock_env + return bot + + +# --------------------------------------------------------------------------- +# Unit tests +# --------------------------------------------------------------------------- + +@pytest.mark.unit +class TestLogAnalysisBotUnit: + """Unit tests for LogAnalysisBot.__init__ — no Docker or LLM required.""" + + def test_default_additional_tools_is_empty_list(self): + """When additional_tools is not passed, the bot has an empty tools list.""" + bot = _make_log_bot() + assert bot.additional_tools == [] + + def test_none_additional_tools_normalised_to_empty_list(self): + """Passing additional_tools=None explicitly should also yield an empty list.""" + bot = _make_log_bot(additional_tools=None) + assert bot.additional_tools == [] + + def test_additional_tools_passed_through(self): + """User-supplied tools are stored on the bot.""" + extra_tool = MagicMock(spec=ToolAbstract) + extra_tool.install_tool = MagicMock() + extra_tool.verify_tool_installation = MagicMock() + extra_tool.usage_instructions_to_llm = None + + bot = _make_log_bot(additional_tools=[extra_tool]) + assert extra_tool in bot.additional_tools + + def test_two_instances_do_not_share_tools_list(self): + """Each instance must get its own list — no shared mutable default.""" + bot1 = _make_log_bot() + bot2 = _make_log_bot() + assert bot1.additional_tools is not bot2.additional_tools + + def test_token_provider_stored(self): + """An explicit token_provider is forwarded to MicroBot.""" + provider = MagicMock(return_value="tok") + bot = _make_log_bot(token_provider=provider) + assert bot.token_provider is provider + + def test_system_prompt_contains_log_file_dir(self): + """The system prompt must reference the log file directory.""" + from microbots.constants import LOG_FILE_DIR + bot = _make_log_bot() + assert LOG_FILE_DIR in bot.system_prompt + + def test_folder_mount_sandbox_path_uses_basename(self): + """The sandbox path is derived from the basename of folder_to_mount.""" + from microbots.constants import DOCKER_WORKING_DIR + bot = _make_log_bot() + assert bot.folder_to_mount.sandbox_path == f"/{DOCKER_WORKING_DIR}/fake-repo" @pytest.mark.integration @pytest.mark.docker diff --git a/test/bot/test_microbot.py b/test/bot/test_microbot.py index b462da4d..bcea0242 100644 --- a/test/bot/test_microbot.py +++ b/test/bot/test_microbot.py @@ -732,6 +732,77 @@ def mock_ask(message: str): # Warning should be logged assert "Failed to parse command output as JSON, using raw stdout" in caplog.text + def test_explicit_token_provider_is_stored(self): + """When token_provider is passed explicitly it is stored as-is, regardless of env.""" + mock_env = Mock() + mock_env.execute.return_value = Mock(return_code=0, stdout="", stderr="") + my_provider = Mock(return_value="tok") + + with patch('microbots.llm.openai_api.AzureOpenAI'): + bot = MicroBot( + model="azure-openai/test-model", + system_prompt="test", + environment=mock_env, + token_provider=my_provider, + ) + + assert bot.token_provider is my_provider + + def test_azure_ad_env_creates_token_provider_for_openai(self): + """When AZURE_AUTH_METHOD=azure_ad and provider is openai, token_provider is auto-created.""" + mock_env = Mock() + mock_env.execute.return_value = Mock(return_code=0, stdout="", stderr="") + mock_credential = Mock() + mock_provider = Mock() + + with patch.dict('os.environ', {'AZURE_AUTH_METHOD': 'azure_ad'}), \ + patch('microbots.MicroBot.DefaultAzureCredential', return_value=mock_credential), \ + patch('microbots.MicroBot.get_bearer_token_provider', return_value=mock_provider) as mock_gbtp, \ + patch('microbots.llm.openai_api.AzureOpenAI'): + bot = MicroBot( + model="azure-openai/test-model", + system_prompt="test", + environment=mock_env, + ) + + mock_gbtp.assert_called_once_with( + mock_credential, "https://cognitiveservices.azure.com/.default" + ) + assert bot.token_provider is mock_provider + + def test_azure_ad_env_does_not_create_token_provider_for_anthropic(self): + """When AZURE_AUTH_METHOD=azure_ad but provider is anthropic, no auto token_provider is created.""" + mock_env = Mock() + mock_env.execute.return_value = Mock(return_code=0, stdout="", stderr="") + + with patch.dict('os.environ', {'AZURE_AUTH_METHOD': 'azure_ad'}), \ + patch('microbots.MicroBot.DefaultAzureCredential') as mock_cred_cls, \ + patch('microbots.llm.anthropic_api.Anthropic'): + bot = MicroBot( + model="anthropic/claude-sonnet-4-5", + system_prompt="test", + environment=mock_env, + ) + + mock_cred_cls.assert_not_called() + assert bot.token_provider is None + + def test_no_azure_ad_env_leaves_token_provider_none(self): + """When AZURE_AUTH_METHOD is not set, token_provider defaults to None.""" + mock_env = Mock() + mock_env.execute.return_value = Mock(return_code=0, stdout="", stderr="") + + env_without_azure = {k: v for k, v in os.environ.items() if k != 'AZURE_AUTH_METHOD'} + with patch.dict('os.environ', env_without_azure, clear=True), \ + patch('microbots.llm.openai_api.OpenAI'): + bot = MicroBot( + model="azure-openai/test-model", + system_prompt="test", + environment=mock_env, + ) + + assert bot.token_provider is None + @pytest.mark.integration @pytest.mark.docker diff --git a/test/llm/test_anthropic_api.py b/test/llm/test_anthropic_api.py index 674294cf..463fdffc 100644 --- a/test/llm/test_anthropic_api.py +++ b/test/llm/test_anthropic_api.py @@ -81,6 +81,24 @@ def test_init_creates_anthropic_client(self): assert api.ai_client is not None + def test_init_raises_when_no_auth_configured(self): + """ValueError is raised when neither api_key nor token_provider is supplied.""" + with patch('microbots.llm.anthropic_api.api_key', None): + with pytest.raises(ValueError, match="No authentication configured for Anthropic"): + AnthropicApi(system_prompt="test", token_provider=None) + + def test_init_with_token_provider_creates_foundry_client(self): + """When token_provider is given, AnthropicFoundry is used instead of Anthropic.""" + mock_provider = Mock(return_value="token") + + with patch('microbots.llm.anthropic_api.api_key', None), \ + patch('microbots.llm.anthropic_api.AnthropicFoundry') as mock_foundry: + api = AnthropicApi(system_prompt="test", token_provider=mock_provider) + + mock_foundry.assert_called_once() + assert mock_foundry.call_args.kwargs['azure_ad_token_provider'] is mock_provider + assert api.token_provider is mock_provider + @pytest.mark.unit class TestAnthropicApiAsk: diff --git a/test/llm/test_openai_api.py b/test/llm/test_openai_api.py index 571c034d..5367e095 100644 --- a/test/llm/test_openai_api.py +++ b/test/llm/test_openai_api.py @@ -78,6 +78,24 @@ def test_init_creates_openai_client(self): assert api.ai_client is not None + def test_init_raises_when_no_auth_configured(self): + """ValueError is raised when neither api_key nor token_provider is supplied.""" + with patch('microbots.llm.openai_api.api_key', None): + with pytest.raises(ValueError, match="No authentication configured for OpenAI"): + OpenAIApi(system_prompt="test", token_provider=None) + + def test_init_with_token_provider_creates_azure_client(self): + """When token_provider is given, AzureOpenAI is used instead of OpenAI.""" + mock_provider = Mock(return_value="token") + + with patch('microbots.llm.openai_api.api_key', None), \ + patch('microbots.llm.openai_api.AzureOpenAI') as mock_azure: + api = OpenAIApi(system_prompt="test", token_provider=mock_provider) + + mock_azure.assert_called_once() + assert mock_azure.call_args.kwargs['azure_ad_token_provider'] is mock_provider + assert api.token_provider is mock_provider + @pytest.mark.unit class TestOpenAIApiAsk: diff --git a/test/tools/tool_definitions/test_microbot_sub_agent.py b/test/tools/tool_definitions/test_microbot_sub_agent.py index 4ef0227d..44361b73 100644 --- a/test/tools/tool_definitions/test_microbot_sub_agent.py +++ b/test/tools/tool_definitions/test_microbot_sub_agent.py @@ -36,6 +36,7 @@ def _make_parent_bot(**overrides): bot.max_iterations = overrides.get("max_iterations", 50) bot.iteration_count = overrides.get("iteration_count", 0) bot.environment = overrides.get("environment", MagicMock()) + bot.token_provider = overrides.get("token_provider", None) return bot