Skip to content
Open
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
127 changes: 127 additions & 0 deletions docs/authentication.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 24 additions & 5 deletions src/microbots/MicroBot.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from collections.abc import Iterable
import json
import os
from pprint import pformat
import re
import time
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -327,15 +344,17 @@ 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(
system_prompt=system_prompt_with_tools, model_name=self.deployment_name
)
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

Expand Down
21 changes: 14 additions & 7 deletions src/microbots/bot/BrowsingBot.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import shlex
from typing import Optional

from microbots.MicroBot import BotType, MicroBot, BotRunResult
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions src/microbots/bot/LogAnalysisBot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
6 changes: 4 additions & 2 deletions src/microbots/bot/ReadingBot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
6 changes: 4 additions & 2 deletions src/microbots/bot/WritingBot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
30 changes: 24 additions & 6 deletions src/microbots/llm/anthropic_api.py
Original file line number Diff line number Diff line change
@@ -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__)
Expand All @@ -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 = []
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading