From 20a3784482e7cdd40050717d4eb7bd06bc1de68a Mon Sep 17 00:00:00 2001 From: Kavya Sree Kaitepalli Date: Thu, 7 May 2026 07:12:28 +0000 Subject: [PATCH 1/4] Add openai as separate model provider --- .github/workflows/test.yml | 13 +- README.md | 4 +- docs/authentication.md | 125 +++++-- docs/index.md | 4 +- src/microbots/MicroBot.py | 11 +- src/microbots/constants.py | 3 +- src/microbots/llm/azure_openai_api.py | 87 +++++ src/microbots/llm/openai_api.py | 49 +-- test/bot/test_memory_tool_integration.py | 8 +- test/bot/test_microbot.py | 20 +- test/llm/test_azure_openai_api.py | 441 +++++++++++++++++++++++ test/llm/test_openai_api.py | 236 +++--------- 12 files changed, 706 insertions(+), 295 deletions(-) create mode 100644 src/microbots/llm/azure_openai_api.py create mode 100644 test/llm/test_azure_openai_api.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 04c57d85..1158451d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -124,20 +124,17 @@ jobs: - name: Run ${{ matrix.test-type }} tests env: - # OpenAI API Configuration - OPEN_AI_KEY: ${{ secrets.OPEN_AI_KEY }} - OPEN_AI_DEPLOYMENT_NAME: ${{ secrets.OPEN_AI_DEPLOYMENT_NAME }} - OPEN_AI_END_POINT: ${{ secrets.OPEN_AI_END_POINT }} # Azure OpenAI API Configuration - AZURE_OPENAI_API_VERSION: ${{ secrets.AZURE_OPENAI_API_VERSION }} AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }} - AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }} + AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }} + AZURE_OPENAI_ENDPOINT: ${{ vars.AZURE_OPENAI_ENDPOINT }} + AZURE_OPENAI_API_VERSION: ${{ vars.AZURE_OPENAI_API_VERSION }} BROWSER_USE_LLM_MODEL: "gpt-5" BROWSER_USE_LLM_TEMPERATURE: 1 #Anthrpic API Configuration ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - ANTHROPIC_DEPLOYMENT_NAME: ${{ secrets.ANTHROPIC_DEPLOYMENT_NAME }} - ANTHROPIC_END_POINT: ${{ secrets.ANTHROPIC_END_POINT }} + ANTHROPIC_DEPLOYMENT_NAME: ${{ vars.ANTHROPIC_DEPLOYMENT_NAME }} + ANTHROPIC_END_POINT: ${{ vars.ANTHROPIC_END_POINT }} #Local Model Configuration LOCAL_MODEL_NAME: "qwen2.5-coder:latest" LOCAL_MODEL_PORT: 11434 diff --git a/README.md b/README.md index 235bccd9..4b4316d4 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ pip install microbots Azure OpenAI Models - Add the below environment variables in a `.env` file in the root of your application ```env -OPEN_AI_END_POINT=XXXXXXXXXXXXXXXXXXXXXXXXXX -OPEN_AI_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +AZURE_OPENAI_ENDPOINT=XXXXXXXXXXXXXXXXXXXXXXXXXX +AZURE_OPENAI_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ``` ## 🤖 Bots & Usage Examples diff --git a/docs/authentication.md b/docs/authentication.md index bd984947..9583eed3 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -1,25 +1,69 @@ # Authentication -Microbots supports two authentication methods for LLM providers: +Microbots supports multiple LLM providers, each with their own authentication method. -## 1. API Key Authentication (Default) +## Providers Overview -Set the API key as an environment variable. This is the default and requires no additional setup. +| Provider string | Description | SDK | Authentication | +|---|---|---|---| +| `openai` | OpenAI or Azure OpenAI via OpenAI SDK | OpenAI SDK | API key | +| `azure-openai` | Azure OpenAI via Azure SDK | AzureOpenAI SDK | API key or Azure AD token | +| `anthropic` | Anthropic models | Anthropic SDK | API key or Azure AD token (Foundry) | +| `ollama-local` | Local models via Ollama | — | None (local) | + +--- + +## 1. OpenAI (Direct) + +Uses the **OpenAI SDK** with an API key. This works for both: +- **OpenAI directly** (api.openai.com) +- **Azure OpenAI via OpenAI SDK compatibility** (Azure endpoint + API key) ```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" +export OPENAI_API_KEY="your-api-key" +export OPENAI_ENDPOINT="https://api.openai.com/v1" # or your Azure endpoint +``` -# For Anthropic -export ANTHROPIC_API_KEY="your-api-key" -export ANTHROPIC_END_POINT="https://your-endpoint" -export ANTHROPIC_DEPLOYMENT_NAME="your-deployment" +For Azure-hosted models using the OpenAI SDK (as shown in Azure Foundry portal): +```bash +export OPENAI_API_KEY="your-azure-api-key" +export OPENAI_ENDPOINT="https://your-resource.openai.azure.com/openai/v1/" +``` + +Usage: +```python +from microbots.MicroBot import MicroBot + +bot = MicroBot(model="openai/gpt-5") +``` + +> **When to use this:** Use the `openai` provider when you have an API key and want to use the OpenAI SDK — whether pointing at OpenAI directly or at an Azure OpenAI endpoint that supports the OpenAI SDK. + +--- + +## 2. Azure OpenAI (Azure SDK) + +Uses the **AzureOpenAI SDK**. Use this provider when you need **Azure AD token authentication** or prefer the Azure-specific SDK. + +### API Key Authentication (Default) + +```bash +export AZURE_OPENAI_API_KEY="your-api-key" +export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com" +export AZURE_OPENAI_API_VERSION="2025-03-01-preview" +export AZURE_OPENAI_DEPLOYMENT_NAME="your-deployment" +``` + +> **Note:** The Responses API requires `api-version` `2025-03-01-preview` or later. Earlier versions will return a `400 BadRequest` error. + +Usage: +```python +from microbots.MicroBot import MicroBot + +bot = MicroBot(model="azure-openai/your-deployment") ``` -## 2. Azure AD Token Authentication +### 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`. @@ -29,7 +73,7 @@ For environments that require Azure AD authentication (no static API keys), Micr pip install microbots[azure_ad] ``` -### Option A: Environment Variable Opt-In +#### 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. @@ -56,27 +100,14 @@ 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" +export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com" +export AZURE_OPENAI_API_VERSION="2024-02-01" +export AZURE_OPENAI_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 - -First install the optional dependency: - -```bash -pip install microbots[azure_ad] -``` - -Then pass any `Callable[[], str]` as `token_provider`. +#### Option B: Pass a Token Provider Programmatically ```python from azure.identity import DefaultAzureCredential, get_bearer_token_provider @@ -113,25 +144,47 @@ bot = MicroBot( ) ``` -### How Token Refresh Works +--- + +## 3. Anthropic + +```bash +export ANTHROPIC_API_KEY="your-api-key" +export ANTHROPIC_END_POINT="https://your-endpoint" +export ANTHROPIC_DEPLOYMENT_NAME="your-deployment" +``` + +Usage: +```python +from microbots.MicroBot import MicroBot + +bot = MicroBot(model="anthropic/your-deployment") +``` + +For Anthropic on Azure AI Foundry, pass a `token_provider` explicitly (see Option B above with the appropriate Foundry scope). + +--- + +## 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 +## 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=...)` | +| No | `azure-openai` | `AzureOpenAI(api_key=...)` | +| — | `openai` | `OpenAI(base_url=..., api_key=...)` | | Yes | `anthropic` | `AnthropicFoundry(azure_ad_token_provider=...)` | | No | `anthropic` | `Anthropic(api_key=...)` | -`OllamaLocal` (local models) does not use token authentication. +`ollama-local` does not use token authentication. -### Notes +## 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/docs/index.md b/docs/index.md index 97ca9843..38ed8cd4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -53,8 +53,8 @@ MicroBots creates a containerized environment and mounts the specified directory Azure OpenAI Models — add environment variables in a `.env` file: ```env -OPEN_AI_END_POINT=your-endpoint-url -OPEN_AI_KEY=your-api-key +AZURE_OPENAI_ENDPOINT=your-endpoint-url +AZURE_OPENAI_API_KEY=your-api-key ``` ## 📚 Links diff --git a/src/microbots/MicroBot.py b/src/microbots/MicroBot.py index a8c9b7a1..7402288f 100644 --- a/src/microbots/MicroBot.py +++ b/src/microbots/MicroBot.py @@ -14,6 +14,7 @@ LocalDockerEnvironment, ) from microbots.llm.anthropic_api import AnthropicApi +from microbots.llm.azure_openai_api import AzureOpenAIApi from microbots.llm.openai_api import OpenAIApi from microbots.llm.ollama_local import OllamaLocal from microbots.llm.llm import llm_output_format_str @@ -170,7 +171,7 @@ def __init__( self.token_provider = token_provider elif ( os.getenv("AZURE_AUTH_METHOD", "").strip().lower() == "azure_ad" - and self.model_provider == ModelProvider.OPENAI + and self.model_provider == ModelProvider.AZURE_OPENAI ): try: from azure.identity import DefaultAzureCredential, get_bearer_token_provider @@ -348,11 +349,15 @@ def _create_llm(self): if tool.usage_instructions_to_llm: system_prompt_with_tools += f"\n\n{tool.usage_instructions_to_llm}" - if self.model_provider == ModelProvider.OPENAI: - self.llm = OpenAIApi( + if self.model_provider == ModelProvider.AZURE_OPENAI: + self.llm = AzureOpenAIApi( system_prompt=system_prompt_with_tools, deployment_name=self.deployment_name, token_provider=self.token_provider, ) + elif self.model_provider == ModelProvider.OPENAI: + self.llm = OpenAIApi( + system_prompt=system_prompt_with_tools, deployment_name=self.deployment_name, + ) elif self.model_provider == ModelProvider.OLLAMA_LOCAL: self.llm = OllamaLocal( system_prompt=system_prompt_with_tools, model_name=self.deployment_name diff --git a/src/microbots/constants.py b/src/microbots/constants.py index c4a7ff98..3b8f956b 100644 --- a/src/microbots/constants.py +++ b/src/microbots/constants.py @@ -3,7 +3,8 @@ class ModelProvider(StrEnum): - OPENAI = "azure-openai" + AZURE_OPENAI = "azure-openai" + OPENAI = "openai" OLLAMA_LOCAL = "ollama-local" ANTHROPIC = "anthropic" diff --git a/src/microbots/llm/azure_openai_api.py b/src/microbots/llm/azure_openai_api.py new file mode 100644 index 00000000..d27c8d40 --- /dev/null +++ b/src/microbots/llm/azure_openai_api.py @@ -0,0 +1,87 @@ +import json +import os +from collections.abc import Callable +from dataclasses import asdict + +from dotenv import load_dotenv +from openai import AzureOpenAI +from microbots.llm.llm import LLMAskResponse, LLMInterface + +load_dotenv() + +endpoint = os.getenv("AZURE_OPENAI_ENDPOINT") +api_version = os.getenv("AZURE_OPENAI_API_VERSION") +deployment_name = os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME") +api_key = os.getenv("AZURE_OPENAI_API_KEY") + + +class AzureOpenAIApi(LLMInterface): + + 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 Azure OpenAI. Either set the AZURE_OPENAI_API_KEY " + "environment variable or provide a token_provider (e.g. AzureTokenProvider)." + ) + + if token_provider: + if not callable(token_provider): + raise ValueError("token_provider must be a callable that returns a string token.") + try: + token = token_provider() + except Exception as e: + raise ValueError(f"token_provider failed during validation: {e}") from e + if not isinstance(token, str) or not token: + raise ValueError("token_provider must return a non-empty string token.") + self.ai_client = AzureOpenAI( + azure_endpoint=endpoint, + azure_ad_token_provider=token_provider, + api_version=api_version, + ) + else: + # Azure OpenAI with API key + self.ai_client = AzureOpenAI( + azure_endpoint=endpoint, + api_key=api_key, + api_version=api_version, + ) + self.deployment_name = deployment_name + self.system_prompt = system_prompt + self.messages = [{"role": "system", "content": system_prompt}] + + # Set these values here. This logic will be handled in the parent class. + self.max_retries = max_retries + self.retries = 0 + + def ask(self, message) -> LLMAskResponse: + self.retries = 0 # reset retries for each ask. Handled in parent class. + + self.messages.append({"role": "user", "content": message}) + + valid = False + while not valid: + response = self.ai_client.responses.create( + model=self.deployment_name, + input=self.messages, + ) + self.messages.append({"role": "assistant", "content": response.output_text}) + valid, askResponse = self._validate_llm_response(response=response.output_text) + + # Remove last assistant message and replace with structured response + self.messages.pop() + self.messages.append({"role": "assistant", "content": json.dumps(asdict(askResponse))}) + + return askResponse + + def clear_history(self): + self.messages = [ + { + "role": "system", + "content": self.system_prompt, + } + ] + return True + diff --git a/src/microbots/llm/openai_api.py b/src/microbots/llm/openai_api.py index 5713d5d6..258a0055 100644 --- a/src/microbots/llm/openai_api.py +++ b/src/microbots/llm/openai_api.py @@ -1,63 +1,39 @@ import json import os -from collections.abc import Callable from dataclasses import asdict from dotenv import load_dotenv -from openai import AzureOpenAI, OpenAI +from openai import 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") +endpoint = os.getenv("OPENAI_ENDPOINT", "https://api.openai.com/v1") +api_key = os.getenv("OPENAI_API_KEY") class OpenAIApi(LLMInterface): - 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: + def __init__(self, system_prompt, deployment_name, max_retries=3): + if 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)." + "No authentication configured for OpenAI. " + "Set the OPENAI_API_KEY environment variable." ) - if token_provider: - if not callable(token_provider): - raise ValueError("token_provider must be a callable that returns a string token.") - try: - token = token_provider() - except Exception as e: - raise ValueError(f"token_provider failed during validation: {e}") from e - if not isinstance(token, str) or not token: - raise ValueError("token_provider must return a non-empty string token.") - # 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.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}] - # Set these values here. This logic will be handled in the parent class. self.max_retries = max_retries self.retries = 0 def ask(self, message) -> LLMAskResponse: - self.retries = 0 # reset retries for each ask. Handled in parent class. + self.retries = 0 self.messages.append({"role": "user", "content": message}) @@ -84,4 +60,3 @@ def clear_history(self): } ] return True - diff --git a/test/bot/test_memory_tool_integration.py b/test/bot/test_memory_tool_integration.py index 3c03df5c..bdaa76bf 100644 --- a/test/bot/test_memory_tool_integration.py +++ b/test/bot/test_memory_tool_integration.py @@ -262,10 +262,10 @@ def bot(self, memory_dir): openai_deployment = "gpt-4" - with patch("microbots.llm.openai_api.OpenAI") as mock_openai_cls, \ - patch("microbots.llm.openai_api.api_key", "test-key"), \ - patch("microbots.llm.openai_api.endpoint", "https://api.openai.com"), \ - patch("microbots.llm.openai_api.deployment_name", openai_deployment): + with patch("microbots.llm.azure_openai_api.AzureOpenAI") as mock_openai_cls, \ + patch("microbots.llm.azure_openai_api.api_key", "test-key"), \ + patch("microbots.llm.azure_openai_api.endpoint", "https://test-resource.openai.azure.com"), \ + patch("microbots.llm.azure_openai_api.deployment_name", openai_deployment): bot = MicroBot( model=f"azure-openai/{openai_deployment}", diff --git a/test/bot/test_microbot.py b/test/bot/test_microbot.py index 5a4161b8..339b421b 100644 --- a/test/bot/test_microbot.py +++ b/test/bot/test_microbot.py @@ -502,7 +502,7 @@ def test_tool_usage_instructions_appended_to_system_prompt(self): mock_env.execute.return_value = Mock(return_code=0, stdout="", stderr="") # Mock the environment and LLM creation to avoid actual Docker/API calls - with patch('microbots.llm.openai_api.OpenAI'): + with patch('microbots.llm.azure_openai_api.AzureOpenAI'): # Create a MicroBot with the mock tool bot = MicroBot( model="azure-openai/test-model", @@ -513,8 +513,8 @@ def test_tool_usage_instructions_appended_to_system_prompt(self): # Verify that the LLM was created with the combined system prompt # The system prompt should include both the base prompt and the tool usage instructions - from microbots.llm.openai_api import OpenAIApi - assert isinstance(bot.llm, OpenAIApi) + from microbots.llm.azure_openai_api import AzureOpenAIApi + assert isinstance(bot.llm, AzureOpenAIApi) assert base_system_prompt in bot.llm.system_prompt assert "# Test Tool Usage" in bot.llm.system_prompt assert "Use this tool for testing purposes only." in bot.llm.system_prompt @@ -599,7 +599,7 @@ def test_run_json_output_with_content_key(self): return_code=0, stdout=json.dumps(json_content), stderr="" ) - with patch('microbots.llm.openai_api.OpenAI'): + with patch('microbots.llm.azure_openai_api.AzureOpenAI'): bot = MicroBot( model="azure-openai/test-model", system_prompt="test prompt", @@ -634,7 +634,7 @@ def test_run_json_output_without_content_key(self): return_code=0, stdout=raw_stdout, stderr="" ) - with patch('microbots.llm.openai_api.OpenAI'): + with patch('microbots.llm.azure_openai_api.AzureOpenAI'): bot = MicroBot( model="azure-openai/test-model", system_prompt="test prompt", @@ -668,7 +668,7 @@ def test_run_non_json_output_json_decode_error(self): return_code=0, stdout=raw_stdout, stderr="" ) - with patch('microbots.llm.openai_api.OpenAI'): + with patch('microbots.llm.azure_openai_api.AzureOpenAI'): bot = MicroBot( model="azure-openai/test-model", system_prompt="test prompt", @@ -702,7 +702,7 @@ def test_run_json_parse_blanket_exception(self, caplog): return_code=0, stdout=raw_stdout, stderr="" ) - with patch('microbots.llm.openai_api.OpenAI'): + with patch('microbots.llm.azure_openai_api.AzureOpenAI'): bot = MicroBot( model="azure-openai/test-model", system_prompt="test prompt", @@ -738,7 +738,7 @@ def test_explicit_token_provider_is_stored(self): mock_env.execute.return_value = Mock(return_code=0, stdout="", stderr="") my_provider = Mock(return_value="tok") - with patch('microbots.llm.openai_api.AzureOpenAI'): + with patch('microbots.llm.azure_openai_api.AzureOpenAI'): bot = MicroBot( model="azure-openai/test-model", system_prompt="test", @@ -761,7 +761,7 @@ def test_azure_ad_env_creates_token_provider_for_openai(self): with patch.dict('os.environ', {'AZURE_AUTH_METHOD': 'azure_ad'}), \ patch.dict('sys.modules', {'azure': MagicMock(), 'azure.identity': mock_azure_identity}), \ - patch('microbots.llm.openai_api.AzureOpenAI'): + patch('microbots.llm.azure_openai_api.AzureOpenAI'): bot = MicroBot( model="azure-openai/test-model", system_prompt="test", @@ -813,7 +813,7 @@ def test_no_azure_ad_env_leaves_token_provider_none(self): 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'): + patch('microbots.llm.azure_openai_api.AzureOpenAI'): bot = MicroBot( model="azure-openai/test-model", system_prompt="test", diff --git a/test/llm/test_azure_openai_api.py b/test/llm/test_azure_openai_api.py new file mode 100644 index 00000000..75867b86 --- /dev/null +++ b/test/llm/test_azure_openai_api.py @@ -0,0 +1,441 @@ +""" +Unit tests for AzureOpenAIApi class +""" +import pytest +import json +import sys +import os +from unittest.mock import Mock, patch, MagicMock +from dataclasses import asdict + +# Add src to path for imports +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) + +from microbots.llm.azure_openai_api import AzureOpenAIApi +from microbots.llm.llm import LLMAskResponse, LLMInterface + + +@pytest.fixture(autouse=True) +def patch_openai_config(): + """Automatically patch OpenAI configuration for all tests""" + with patch('microbots.llm.azure_openai_api.endpoint', 'https://api.openai.com'), \ + patch('microbots.llm.azure_openai_api.deployment_name', 'gpt-4'), \ + patch('microbots.llm.azure_openai_api.api_key', 'test-api-key'), \ + patch('microbots.llm.azure_openai_api.AzureOpenAI') as mock_azure_openai: + yield mock_azure_openai + + +@pytest.mark.unit +class TestAzureOpenAIApiInitialization: + """Tests for AzureOpenAIApi initialization""" + + def test_init_with_default_deployment_name(self): + """Test initialization with deployment name from parameter default""" + system_prompt = "You are a helpful assistant" + + # When no deployment_name is passed, it uses the default parameter value + # which comes from the module-level variable at function definition time + api = AzureOpenAIApi(system_prompt=system_prompt) + + assert api.system_prompt == system_prompt + # The deployment_name will be whatever the module variable was set to + # In test environment with mocked config, this might be None or the patched value + assert api.max_retries == 3 + assert api.retries == 0 + assert len(api.messages) == 1 + assert api.messages[0]["role"] == "system" + assert api.messages[0]["content"] == system_prompt + + def test_init_with_custom_deployment_name(self): + """Test initialization with custom deployment name""" + system_prompt = "You are a helpful assistant" + custom_deployment = "gpt-3.5-turbo" + + api = AzureOpenAIApi( + system_prompt=system_prompt, + deployment_name=custom_deployment + ) + + assert api.deployment_name == custom_deployment + + def test_init_with_custom_max_retries(self): + """Test initialization with custom max_retries""" + system_prompt = "You are a helpful assistant" + + api = AzureOpenAIApi( + system_prompt=system_prompt, + max_retries=5 + ) + + assert api.max_retries == 5 + assert api.retries == 0 + + def test_init_creates_openai_client(self): + """Test that initialization creates OpenAI client""" + system_prompt = "You are a helpful assistant" + + api = AzureOpenAIApi(system_prompt=system_prompt) + + 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.azure_openai_api.api_key', None): + with pytest.raises(ValueError, match="No authentication configured for Azure OpenAI"): + AzureOpenAIApi(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.azure_openai_api.api_key', None), \ + patch('microbots.llm.azure_openai_api.AzureOpenAI') as mock_azure: + api = AzureOpenAIApi(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 + + def test_init_raises_when_token_provider_not_callable(self): + """ValueError is raised when token_provider is not callable.""" + with patch('microbots.llm.azure_openai_api.api_key', None): + with pytest.raises(ValueError, match="token_provider must be a callable"): + AzureOpenAIApi(system_prompt="test", token_provider="not-a-callable") + + def test_init_raises_when_token_provider_raises(self): + """ValueError is raised when token_provider raises an exception during validation.""" + failing_provider = Mock(side_effect=RuntimeError("credential error")) + with patch('microbots.llm.azure_openai_api.api_key', None): + with pytest.raises(ValueError, match="token_provider failed during validation"): + AzureOpenAIApi(system_prompt="test", token_provider=failing_provider) + + def test_init_raises_when_token_provider_returns_empty_string(self): + """ValueError is raised when token_provider returns an empty string.""" + with patch('microbots.llm.azure_openai_api.api_key', None): + with pytest.raises(ValueError, match="token_provider must return a non-empty string token"): + AzureOpenAIApi(system_prompt="test", token_provider=lambda: "") + + def test_init_raises_when_token_provider_returns_non_string(self): + """ValueError is raised when token_provider returns a non-string.""" + with patch('microbots.llm.azure_openai_api.api_key', None): + with pytest.raises(ValueError, match="token_provider must return a non-empty string token"): + AzureOpenAIApi(system_prompt="test", token_provider=lambda: 12345) +class TestAzureOpenAIApiAsk: + """Tests for AzureOpenAIApi.ask method""" + + def test_ask_successful_response(self): + """Test ask method with successful response""" + system_prompt = "You are a helpful assistant" + api = AzureOpenAIApi(system_prompt=system_prompt) + + # Mock the OpenAI client response + mock_response = Mock() + mock_response.output_text = json.dumps({ + "task_done": False, + "command": "echo 'hello'", + "thoughts": None + }) + api.ai_client.responses.create = Mock(return_value=mock_response) + + # Call ask + message = "Please say hello" + result = api.ask(message) + + # Verify the result + assert isinstance(result, LLMAskResponse) + assert result.task_done is False + assert result.command == "echo 'hello'" + assert result.thoughts == "" or result.thoughts is None + + # Verify retries was reset + assert api.retries == 0 + + # Verify messages were appended + assert len(api.messages) == 3 # system + user + assistant + assert api.messages[1]["role"] == "user" + assert api.messages[1]["content"] == message + assert api.messages[2]["role"] == "assistant" + + def test_ask_with_task_done_true(self): + """Test ask method when task is complete""" + system_prompt = "You are a helpful assistant" + api = AzureOpenAIApi(system_prompt=system_prompt) + + # Mock the OpenAI client response + mock_response = Mock() + mock_response.output_text = json.dumps({ + "task_done": True, + "command": "", + "thoughts": "Task completed successfully" + }) + api.ai_client.responses.create = Mock(return_value=mock_response) + + # Call ask + result = api.ask("Complete the task") + + # Verify the result + assert result.task_done is True + assert result.command == "" + assert result.thoughts == "Task completed successfully" + + def test_ask_with_retry_on_invalid_response(self): + """Test ask method retries on invalid response then succeeds""" + system_prompt = "You are a helpful assistant" + api = AzureOpenAIApi(system_prompt=system_prompt) + + # Mock the OpenAI client to return invalid then valid response + mock_invalid_response = Mock() + mock_invalid_response.output_text = "invalid json" + + mock_valid_response = Mock() + mock_valid_response.output_text = json.dumps({ + "task_done": False, + "command": "ls -la", + "thoughts": None + }) + + api.ai_client.responses.create = Mock( + side_effect=[mock_invalid_response, mock_valid_response] + ) + + # Call ask + result = api.ask("List files") + + # Verify it eventually succeeded + assert result.task_done is False + assert result.command == "ls -la" + + # Verify it called the API twice (retry happened) + assert api.ai_client.responses.create.call_count == 2 + + def test_ask_appends_user_message(self): + """Test that ask appends user message to messages list""" + system_prompt = "You are a helpful assistant" + api = AzureOpenAIApi(system_prompt=system_prompt) + + initial_message_count = len(api.messages) + + # Mock the OpenAI client response + mock_response = Mock() + mock_response.output_text = json.dumps({ + "task_done": False, + "command": "pwd", + "thoughts": None + }) + api.ai_client.responses.create = Mock(return_value=mock_response) + + # Call ask + user_message = "What directory am I in?" + api.ask(user_message) + + # Verify user message was added + assert len(api.messages) > initial_message_count + user_messages = [m for m in api.messages if m["role"] == "user"] + assert user_messages[-1]["content"] == user_message + + def test_ask_appends_assistant_response_as_json(self): + """Test that ask appends assistant response as JSON string""" + system_prompt = "You are a helpful assistant" + api = AzureOpenAIApi(system_prompt=system_prompt) + + # Mock the OpenAI client response + mock_response = Mock() + mock_response.output_text = json.dumps({ + "task_done": False, + "command": "echo test", + "thoughts": None + }) + api.ai_client.responses.create = Mock(return_value=mock_response) + + # Call ask + api.ask("Run echo test") + + # Verify assistant message was added as JSON + assistant_messages = [m for m in api.messages if m["role"] == "assistant"] + assert len(assistant_messages) > 0 + + # Parse the assistant message to verify it's valid JSON + assistant_content = json.loads(assistant_messages[-1]["content"]) + assert assistant_content["task_done"] is False + assert assistant_content["command"] == "echo test" + assert assistant_content["thoughts"] is None + + def test_ask_uses_asdict_for_response(self): + """Test that ask uses asdict to convert LLMAskResponse to dict""" + system_prompt = "You are a helpful assistant" + api = AzureOpenAIApi(system_prompt=system_prompt) + + # Mock the OpenAI client response + mock_response = Mock() + response_dict = { + "task_done": True, + "command": "", + "thoughts": "Done" + } + mock_response.output_text = json.dumps(response_dict) + api.ai_client.responses.create = Mock(return_value=mock_response) + + # Call ask + result = api.ask("Complete task") + + # Verify the assistant message contains the correct structure + assistant_msg = json.loads(api.messages[-1]["content"]) + + # Verify it matches what asdict would produce + expected = asdict(result) + assert assistant_msg == expected + + def test_ask_resets_retries_to_zero(self): + """Test that ask resets retries to 0 at the start""" + system_prompt = "You are a helpful assistant" + api = AzureOpenAIApi(system_prompt=system_prompt) + + # Set retries to a non-zero value + api.retries = 5 + + # Mock the OpenAI client response + mock_response = Mock() + mock_response.output_text = json.dumps({ + "task_done": False, + "command": "ls", + "thoughts": None + }) + api.ai_client.responses.create = Mock(return_value=mock_response) + + # Call ask + api.ask("List files") + + # Verify retries was reset to 0 + assert api.retries == 0 + + +@pytest.mark.unit +class TestAzureOpenAIApiClearHistory: + """Tests for AzureOpenAIApi.clear_history method""" + + def test_clear_history_resets_messages(self): + """Test that clear_history resets messages to only system prompt""" + system_prompt = "You are a helpful assistant" + api = AzureOpenAIApi(system_prompt=system_prompt) + + # Add some messages + api.messages.append({"role": "user", "content": "Hello"}) + api.messages.append({"role": "assistant", "content": "Hi there"}) + api.messages.append({"role": "user", "content": "How are you?"}) + + assert len(api.messages) == 4 # system + 3 added + + # Clear history + result = api.clear_history() + + # Verify only system message remains + assert result is True + assert len(api.messages) == 1 + assert api.messages[0]["role"] == "system" + assert api.messages[0]["content"] == system_prompt + + def test_clear_history_returns_true(self): + """Test that clear_history returns True""" + system_prompt = "You are a helpful assistant" + api = AzureOpenAIApi(system_prompt=system_prompt) + + result = api.clear_history() + + assert result is True + + def test_clear_history_preserves_system_prompt(self): + """Test that clear_history preserves the original system prompt""" + system_prompt = "You are a code assistant specialized in Python" + api = AzureOpenAIApi(system_prompt=system_prompt) + + # Add and clear messages multiple times + for i in range(3): + api.messages.append({"role": "user", "content": f"Message {i}"}) + api.clear_history() + + # Verify system prompt is still correct + assert len(api.messages) == 1 + assert api.messages[0]["content"] == system_prompt + + +@pytest.mark.unit +class TestAzureOpenAIApiInheritance: + """Tests to verify AzureOpenAIApi correctly inherits from LLMInterface""" + + def test_openai_api_is_llm_interface(self): + """Test that AzureOpenAIApi is an instance of LLMInterface""" + system_prompt = "You are a helpful assistant" + api = AzureOpenAIApi(system_prompt=system_prompt) + + assert isinstance(api, LLMInterface) + + def test_openai_api_implements_ask(self): + """Test that AzureOpenAIApi implements ask method""" + system_prompt = "You are a helpful assistant" + api = AzureOpenAIApi(system_prompt=system_prompt) + + assert hasattr(api, 'ask') + assert callable(api.ask) + + def test_openai_api_implements_clear_history(self): + """Test that AzureOpenAIApi implements clear_history method""" + system_prompt = "You are a helpful assistant" + api = AzureOpenAIApi(system_prompt=system_prompt) + + assert hasattr(api, 'clear_history') + assert callable(api.clear_history) + + +@pytest.mark.unit +class TestAzureOpenAIApiEdgeCases: + """Tests for edge cases and error scenarios""" + + def test_ask_with_empty_message(self): + """Test ask with empty string message""" + system_prompt = "You are a helpful assistant" + api = AzureOpenAIApi(system_prompt=system_prompt) + + # Mock the OpenAI client response + mock_response = Mock() + mock_response.output_text = json.dumps({ + "task_done": False, + "command": "echo ''", + "thoughts": None + }) + api.ai_client.responses.create = Mock(return_value=mock_response) + + # Call ask with empty message + result = api.ask("") + + # Verify it still works + assert isinstance(result, LLMAskResponse) + assert api.messages[-2]["content"] == "" # User message + + def test_multiple_ask_calls_append_messages(self): + """Test that multiple ask calls append all messages""" + system_prompt = "You are a helpful assistant" + api = AzureOpenAIApi(system_prompt=system_prompt) + + # Mock the OpenAI client response + mock_response = Mock() + mock_response.output_text = json.dumps({ + "task_done": False, + "command": "pwd", + "thoughts": None + }) + api.ai_client.responses.create = Mock(return_value=mock_response) + + # Make multiple ask calls + api.ask("First question") + api.ask("Second question") + api.ask("Third question") + + # Verify all messages are preserved + # Should have: 1 system + 3 user + 3 assistant = 7 messages + assert len(api.messages) == 7 + + user_messages = [m for m in api.messages if m["role"] == "user"] + assert len(user_messages) == 3 + assert user_messages[0]["content"] == "First question" + assert user_messages[1]["content"] == "Second question" + assert user_messages[2]["content"] == "Third question" diff --git a/test/llm/test_openai_api.py b/test/llm/test_openai_api.py index e49e562b..442471a8 100644 --- a/test/llm/test_openai_api.py +++ b/test/llm/test_openai_api.py @@ -5,7 +5,7 @@ import json import sys import os -from unittest.mock import Mock, patch, MagicMock +from unittest.mock import Mock, patch from dataclasses import asdict # Add src to path for imports @@ -18,8 +18,7 @@ @pytest.fixture(autouse=True) def patch_openai_config(): """Automatically patch OpenAI configuration for all tests""" - with patch('microbots.llm.openai_api.endpoint', 'https://api.openai.com'), \ - patch('microbots.llm.openai_api.deployment_name', 'gpt-4'), \ + with patch('microbots.llm.openai_api.endpoint', 'https://api.openai.com/v1'), \ patch('microbots.llm.openai_api.api_key', 'test-api-key'), \ patch('microbots.llm.openai_api.OpenAI') as mock_openai: yield mock_openai @@ -29,41 +28,25 @@ def patch_openai_config(): class TestOpenAIApiInitialization: """Tests for OpenAIApi initialization""" - def test_init_with_default_deployment_name(self): - """Test initialization with deployment name from parameter default""" + def test_init_with_deployment_name(self): + """Test initialization with deployment name""" system_prompt = "You are a helpful assistant" - # When no deployment_name is passed, it uses the default parameter value - # which comes from the module-level variable at function definition time - api = OpenAIApi(system_prompt=system_prompt) + api = OpenAIApi(system_prompt=system_prompt, deployment_name="gpt-4") assert api.system_prompt == system_prompt - # The deployment_name will be whatever the module variable was set to - # In test environment with mocked config, this might be None or the patched value + assert api.deployment_name == "gpt-4" assert api.max_retries == 3 assert api.retries == 0 assert len(api.messages) == 1 assert api.messages[0]["role"] == "system" assert api.messages[0]["content"] == system_prompt - def test_init_with_custom_deployment_name(self): - """Test initialization with custom deployment name""" - system_prompt = "You are a helpful assistant" - custom_deployment = "gpt-3.5-turbo" - - api = OpenAIApi( - system_prompt=system_prompt, - deployment_name=custom_deployment - ) - - assert api.deployment_name == custom_deployment - def test_init_with_custom_max_retries(self): """Test initialization with custom max_retries""" - system_prompt = "You are a helpful assistant" - api = OpenAIApi( - system_prompt=system_prompt, + system_prompt="You are a helpful assistant", + deployment_name="gpt-4", max_retries=5 ) @@ -72,63 +55,25 @@ def test_init_with_custom_max_retries(self): def test_init_creates_openai_client(self): """Test that initialization creates OpenAI client""" - system_prompt = "You are a helpful assistant" - - api = OpenAIApi(system_prompt=system_prompt) + api = OpenAIApi(system_prompt="test", deployment_name="gpt-4") 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.""" + def test_init_raises_when_no_api_key(self): + """ValueError is raised when OPENAI_API_KEY is not set.""" 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 - - def test_init_raises_when_token_provider_not_callable(self): - """ValueError is raised when token_provider is not callable.""" - with patch('microbots.llm.openai_api.api_key', None): - with pytest.raises(ValueError, match="token_provider must be a callable"): - OpenAIApi(system_prompt="test", token_provider="not-a-callable") - - def test_init_raises_when_token_provider_raises(self): - """ValueError is raised when token_provider raises an exception during validation.""" - failing_provider = Mock(side_effect=RuntimeError("credential error")) - with patch('microbots.llm.openai_api.api_key', None): - with pytest.raises(ValueError, match="token_provider failed during validation"): - OpenAIApi(system_prompt="test", token_provider=failing_provider) + OpenAIApi(system_prompt="test", deployment_name="gpt-4") - def test_init_raises_when_token_provider_returns_empty_string(self): - """ValueError is raised when token_provider returns an empty string.""" - with patch('microbots.llm.openai_api.api_key', None): - with pytest.raises(ValueError, match="token_provider must return a non-empty string token"): - OpenAIApi(system_prompt="test", token_provider=lambda: "") - def test_init_raises_when_token_provider_returns_non_string(self): - """ValueError is raised when token_provider returns a non-string.""" - with patch('microbots.llm.openai_api.api_key', None): - with pytest.raises(ValueError, match="token_provider must return a non-empty string token"): - OpenAIApi(system_prompt="test", token_provider=lambda: 12345) +@pytest.mark.unit class TestOpenAIApiAsk: """Tests for OpenAIApi.ask method""" def test_ask_successful_response(self): """Test ask method with successful response""" - system_prompt = "You are a helpful assistant" - api = OpenAIApi(system_prompt=system_prompt) + api = OpenAIApi(system_prompt="You are a helpful assistant", deployment_name="gpt-4") - # Mock the OpenAI client response mock_response = Mock() mock_response.output_text = json.dumps({ "task_done": False, @@ -137,31 +82,21 @@ def test_ask_successful_response(self): }) api.ai_client.responses.create = Mock(return_value=mock_response) - # Call ask - message = "Please say hello" - result = api.ask(message) + result = api.ask("Please say hello") - # Verify the result assert isinstance(result, LLMAskResponse) assert result.task_done is False assert result.command == "echo 'hello'" - assert result.thoughts == "" or result.thoughts is None - - # Verify retries was reset assert api.retries == 0 - - # Verify messages were appended assert len(api.messages) == 3 # system + user + assistant assert api.messages[1]["role"] == "user" - assert api.messages[1]["content"] == message + assert api.messages[1]["content"] == "Please say hello" assert api.messages[2]["role"] == "assistant" def test_ask_with_task_done_true(self): """Test ask method when task is complete""" - system_prompt = "You are a helpful assistant" - api = OpenAIApi(system_prompt=system_prompt) + api = OpenAIApi(system_prompt="You are a helpful assistant", deployment_name="gpt-4") - # Mock the OpenAI client response mock_response = Mock() mock_response.output_text = json.dumps({ "task_done": True, @@ -170,20 +105,16 @@ def test_ask_with_task_done_true(self): }) api.ai_client.responses.create = Mock(return_value=mock_response) - # Call ask result = api.ask("Complete the task") - # Verify the result assert result.task_done is True assert result.command == "" assert result.thoughts == "Task completed successfully" def test_ask_with_retry_on_invalid_response(self): """Test ask method retries on invalid response then succeeds""" - system_prompt = "You are a helpful assistant" - api = OpenAIApi(system_prompt=system_prompt) + api = OpenAIApi(system_prompt="You are a helpful assistant", deployment_name="gpt-4") - # Mock the OpenAI client to return invalid then valid response mock_invalid_response = Mock() mock_invalid_response.output_text = "invalid json" @@ -198,24 +129,16 @@ def test_ask_with_retry_on_invalid_response(self): side_effect=[mock_invalid_response, mock_valid_response] ) - # Call ask result = api.ask("List files") - # Verify it eventually succeeded assert result.task_done is False assert result.command == "ls -la" - - # Verify it called the API twice (retry happened) assert api.ai_client.responses.create.call_count == 2 def test_ask_appends_user_message(self): """Test that ask appends user message to messages list""" - system_prompt = "You are a helpful assistant" - api = OpenAIApi(system_prompt=system_prompt) - - initial_message_count = len(api.messages) + api = OpenAIApi(system_prompt="You are a helpful assistant", deployment_name="gpt-4") - # Mock the OpenAI client response mock_response = Mock() mock_response.output_text = json.dumps({ "task_done": False, @@ -224,21 +147,15 @@ def test_ask_appends_user_message(self): }) api.ai_client.responses.create = Mock(return_value=mock_response) - # Call ask - user_message = "What directory am I in?" - api.ask(user_message) + api.ask("What directory am I in?") - # Verify user message was added - assert len(api.messages) > initial_message_count user_messages = [m for m in api.messages if m["role"] == "user"] - assert user_messages[-1]["content"] == user_message + assert user_messages[-1]["content"] == "What directory am I in?" def test_ask_appends_assistant_response_as_json(self): """Test that ask appends assistant response as JSON string""" - system_prompt = "You are a helpful assistant" - api = OpenAIApi(system_prompt=system_prompt) + api = OpenAIApi(system_prompt="You are a helpful assistant", deployment_name="gpt-4") - # Mock the OpenAI client response mock_response = Mock() mock_response.output_text = json.dumps({ "task_done": False, @@ -247,53 +164,20 @@ def test_ask_appends_assistant_response_as_json(self): }) api.ai_client.responses.create = Mock(return_value=mock_response) - # Call ask api.ask("Run echo test") - # Verify assistant message was added as JSON assistant_messages = [m for m in api.messages if m["role"] == "assistant"] assert len(assistant_messages) > 0 - # Parse the assistant message to verify it's valid JSON assistant_content = json.loads(assistant_messages[-1]["content"]) assert assistant_content["task_done"] is False assert assistant_content["command"] == "echo test" - assert assistant_content["thoughts"] is None - - def test_ask_uses_asdict_for_response(self): - """Test that ask uses asdict to convert LLMAskResponse to dict""" - system_prompt = "You are a helpful assistant" - api = OpenAIApi(system_prompt=system_prompt) - - # Mock the OpenAI client response - mock_response = Mock() - response_dict = { - "task_done": True, - "command": "", - "thoughts": "Done" - } - mock_response.output_text = json.dumps(response_dict) - api.ai_client.responses.create = Mock(return_value=mock_response) - - # Call ask - result = api.ask("Complete task") - - # Verify the assistant message contains the correct structure - assistant_msg = json.loads(api.messages[-1]["content"]) - - # Verify it matches what asdict would produce - expected = asdict(result) - assert assistant_msg == expected def test_ask_resets_retries_to_zero(self): """Test that ask resets retries to 0 at the start""" - system_prompt = "You are a helpful assistant" - api = OpenAIApi(system_prompt=system_prompt) - - # Set retries to a non-zero value + api = OpenAIApi(system_prompt="You are a helpful assistant", deployment_name="gpt-4") api.retries = 5 - # Mock the OpenAI client response mock_response = Mock() mock_response.output_text = json.dumps({ "task_done": False, @@ -302,10 +186,8 @@ def test_ask_resets_retries_to_zero(self): }) api.ai_client.responses.create = Mock(return_value=mock_response) - # Call ask api.ask("List files") - # Verify retries was reset to 0 assert api.retries == 0 @@ -316,19 +198,13 @@ class TestOpenAIApiClearHistory: def test_clear_history_resets_messages(self): """Test that clear_history resets messages to only system prompt""" system_prompt = "You are a helpful assistant" - api = OpenAIApi(system_prompt=system_prompt) + api = OpenAIApi(system_prompt=system_prompt, deployment_name="gpt-4") - # Add some messages api.messages.append({"role": "user", "content": "Hello"}) api.messages.append({"role": "assistant", "content": "Hi there"}) - api.messages.append({"role": "user", "content": "How are you?"}) - - assert len(api.messages) == 4 # system + 3 added - # Clear history result = api.clear_history() - # Verify only system message remains assert result is True assert len(api.messages) == 1 assert api.messages[0]["role"] == "system" @@ -336,24 +212,19 @@ def test_clear_history_resets_messages(self): def test_clear_history_returns_true(self): """Test that clear_history returns True""" - system_prompt = "You are a helpful assistant" - api = OpenAIApi(system_prompt=system_prompt) - - result = api.clear_history() + api = OpenAIApi(system_prompt="test", deployment_name="gpt-4") - assert result is True + assert api.clear_history() is True def test_clear_history_preserves_system_prompt(self): """Test that clear_history preserves the original system prompt""" system_prompt = "You are a code assistant specialized in Python" - api = OpenAIApi(system_prompt=system_prompt) + api = OpenAIApi(system_prompt=system_prompt, deployment_name="gpt-4") - # Add and clear messages multiple times for i in range(3): api.messages.append({"role": "user", "content": f"Message {i}"}) api.clear_history() - # Verify system prompt is still correct assert len(api.messages) == 1 assert api.messages[0]["content"] == system_prompt @@ -364,23 +235,20 @@ class TestOpenAIApiInheritance: def test_openai_api_is_llm_interface(self): """Test that OpenAIApi is an instance of LLMInterface""" - system_prompt = "You are a helpful assistant" - api = OpenAIApi(system_prompt=system_prompt) + api = OpenAIApi(system_prompt="test", deployment_name="gpt-4") assert isinstance(api, LLMInterface) def test_openai_api_implements_ask(self): - """Test that OpenAIApi implements ask method""" - system_prompt = "You are a helpful assistant" - api = OpenAIApi(system_prompt=system_prompt) + """Test that OpenAIApi implements the ask method""" + api = OpenAIApi(system_prompt="test", deployment_name="gpt-4") assert hasattr(api, 'ask') assert callable(api.ask) def test_openai_api_implements_clear_history(self): - """Test that OpenAIApi implements clear_history method""" - system_prompt = "You are a helpful assistant" - api = OpenAIApi(system_prompt=system_prompt) + """Test that OpenAIApi implements the clear_history method""" + api = OpenAIApi(system_prompt="test", deployment_name="gpt-4") assert hasattr(api, 'clear_history') assert callable(api.clear_history) @@ -388,54 +256,38 @@ def test_openai_api_implements_clear_history(self): @pytest.mark.unit class TestOpenAIApiEdgeCases: - """Tests for edge cases and error scenarios""" + """Edge case tests for OpenAIApi""" def test_ask_with_empty_message(self): - """Test ask with empty string message""" - system_prompt = "You are a helpful assistant" - api = OpenAIApi(system_prompt=system_prompt) + """Test ask with an empty message string""" + api = OpenAIApi(system_prompt="test", deployment_name="gpt-4") - # Mock the OpenAI client response mock_response = Mock() mock_response.output_text = json.dumps({ "task_done": False, - "command": "echo ''", + "command": "echo empty", "thoughts": None }) api.ai_client.responses.create = Mock(return_value=mock_response) - # Call ask with empty message result = api.ask("") - # Verify it still works - assert isinstance(result, LLMAskResponse) - assert api.messages[-2]["content"] == "" # User message + assert result.command == "echo empty" def test_multiple_ask_calls_append_messages(self): - """Test that multiple ask calls append all messages""" - system_prompt = "You are a helpful assistant" - api = OpenAIApi(system_prompt=system_prompt) + """Test that multiple ask calls accumulate messages""" + api = OpenAIApi(system_prompt="test", deployment_name="gpt-4") - # Mock the OpenAI client response mock_response = Mock() mock_response.output_text = json.dumps({ "task_done": False, - "command": "pwd", + "command": "cmd1", "thoughts": None }) api.ai_client.responses.create = Mock(return_value=mock_response) - # Make multiple ask calls - api.ask("First question") - api.ask("Second question") - api.ask("Third question") - - # Verify all messages are preserved - # Should have: 1 system + 3 user + 3 assistant = 7 messages - assert len(api.messages) == 7 + api.ask("First message") + api.ask("Second message") - user_messages = [m for m in api.messages if m["role"] == "user"] - assert len(user_messages) == 3 - assert user_messages[0]["content"] == "First question" - assert user_messages[1]["content"] == "Second question" - assert user_messages[2]["content"] == "Third question" + # system + (user + assistant) * 2 = 5 + assert len(api.messages) == 5 From cd46ee6667751e9d67c2d32f4158d72584a04532 Mon Sep 17 00:00:00 2001 From: Kavya Sree Kaitepalli Date: Thu, 7 May 2026 08:54:32 +0000 Subject: [PATCH 2/4] Add test for OpenAI provider instantiation in MicroBot --- test/bot/test_microbot.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/bot/test_microbot.py b/test/bot/test_microbot.py index 339b421b..67cfd83c 100644 --- a/test/bot/test_microbot.py +++ b/test/bot/test_microbot.py @@ -822,6 +822,24 @@ def test_no_azure_ad_env_leaves_token_provider_none(self): assert bot.token_provider is None + def test_openai_provider_creates_openai_api_instance(self): + """Test that 'openai' provider creates an OpenAIApi instance.""" + mock_env = Mock() + mock_env.execute.return_value = Mock(return_code=0, stdout="", stderr="") + + with patch('microbots.llm.openai_api.OpenAI'), \ + patch('microbots.llm.openai_api.api_key', 'test-key'): + bot = MicroBot( + model="openai/gpt-4", + system_prompt="test", + environment=mock_env, + ) + + from microbots.llm.openai_api import OpenAIApi + assert isinstance(bot.llm, OpenAIApi) + assert bot.deployment_name == "gpt-4" + assert bot.model_provider == "openai" + @pytest.mark.integration @pytest.mark.docker From 070a2e074fa57d295eb5012ffbb2b2543260a5c3 Mon Sep 17 00:00:00 2001 From: Kavya Sree Kaitepalli Date: Fri, 8 May 2026 06:09:26 +0000 Subject: [PATCH 3/4] Add AZURE_OPENAI_API_VERSION environment variable and validation checks --- README.md | 1 + docs/index.md | 1 + src/microbots/llm/azure_openai_api.py | 12 ++++++++++++ 3 files changed, 14 insertions(+) diff --git a/README.md b/README.md index 4b4316d4..a28723e7 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Azure OpenAI Models - Add the below environment variables in a `.env` file in th ```env AZURE_OPENAI_ENDPOINT=XXXXXXXXXXXXXXXXXXXXXXXXXX AZURE_OPENAI_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +AZURE_OPENAI_API_VERSION=2025-03-01-preview ``` ## 🤖 Bots & Usage Examples diff --git a/docs/index.md b/docs/index.md index 38ed8cd4..7fa858b0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -55,6 +55,7 @@ Azure OpenAI Models — add environment variables in a `.env` file: ```env AZURE_OPENAI_ENDPOINT=your-endpoint-url AZURE_OPENAI_API_KEY=your-api-key +AZURE_OPENAI_API_VERSION=2025-03-01-preview ``` ## 📚 Links diff --git a/src/microbots/llm/azure_openai_api.py b/src/microbots/llm/azure_openai_api.py index d27c8d40..7424df85 100644 --- a/src/microbots/llm/azure_openai_api.py +++ b/src/microbots/llm/azure_openai_api.py @@ -21,6 +21,18 @@ 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 endpoint: + raise ValueError( + "AZURE_OPENAI_ENDPOINT environment variable is required when using Azure OpenAI. " + "Set it to your Azure OpenAI resource endpoint (e.g. 'https://.openai.azure.com/')." + ) + + if not api_version: + raise ValueError( + "AZURE_OPENAI_API_VERSION environment variable is required when using Azure OpenAI. " + "Set it to a valid API version (e.g. '2024-12-01-preview')." + ) + if not token_provider and not api_key: raise ValueError( "No authentication configured for Azure OpenAI. Either set the AZURE_OPENAI_API_KEY " From 2c2f52f0833b986bd4b895527d2ab4e509b72af7 Mon Sep 17 00:00:00 2001 From: Kavya Sree Kaitepalli Date: Fri, 8 May 2026 06:24:51 +0000 Subject: [PATCH 4/4] Add validation tests for AzureOpenAIApi initialization errors --- test/llm/test_azure_openai_api.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/llm/test_azure_openai_api.py b/test/llm/test_azure_openai_api.py index 75867b86..69300c93 100644 --- a/test/llm/test_azure_openai_api.py +++ b/test/llm/test_azure_openai_api.py @@ -19,6 +19,7 @@ def patch_openai_config(): """Automatically patch OpenAI configuration for all tests""" with patch('microbots.llm.azure_openai_api.endpoint', 'https://api.openai.com'), \ + patch('microbots.llm.azure_openai_api.api_version', '2025-03-01-preview'), \ patch('microbots.llm.azure_openai_api.deployment_name', 'gpt-4'), \ patch('microbots.llm.azure_openai_api.api_key', 'test-api-key'), \ patch('microbots.llm.azure_openai_api.AzureOpenAI') as mock_azure_openai: @@ -78,6 +79,18 @@ def test_init_creates_openai_client(self): assert api.ai_client is not None + def test_init_raises_when_endpoint_not_set(self): + """ValueError is raised when AZURE_OPENAI_ENDPOINT is not set.""" + with patch('microbots.llm.azure_openai_api.endpoint', None): + with pytest.raises(ValueError, match="AZURE_OPENAI_ENDPOINT environment variable is required"): + AzureOpenAIApi(system_prompt="test") + + def test_init_raises_when_api_version_not_set(self): + """ValueError is raised when AZURE_OPENAI_API_VERSION is not set.""" + with patch('microbots.llm.azure_openai_api.api_version', None): + with pytest.raises(ValueError, match="AZURE_OPENAI_API_VERSION environment variable is required"): + AzureOpenAIApi(system_prompt="test") + 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.azure_openai_api.api_key', None):