From 813d37ad35ad63e8e66ec4ea1ccbef23b43f06df Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Fri, 6 Mar 2026 08:43:58 +0000 Subject: [PATCH 1/9] Support Azure OpenAI --- nanobot/cli/commands.py | 15 + nanobot/config/schema.py | 1 + nanobot/providers/__init__.py | 3 +- nanobot/providers/azure_openai_provider.py | 192 +++++++++++ nanobot/providers/registry.py | 10 + tests/test_azure_openai_provider.py | 356 +++++++++++++++++++++ 6 files changed, 576 insertions(+), 1 deletion(-) create mode 100644 nanobot/providers/azure_openai_provider.py create mode 100644 tests/test_azure_openai_provider.py diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index aca0778..2d0f3c2 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -203,6 +203,7 @@ def _make_provider(config: Config): from nanobot.providers.custom_provider import CustomProvider from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider + from nanobot.providers.azure_openai_provider import AzureOpenAIProvider model = config.agents.defaults.model provider_name = config.get_provider_name(model) @@ -220,6 +221,20 @@ def _make_provider(config: Config): default_model=model, ) + # Azure OpenAI: direct Azure OpenAI endpoint with deployment name + if provider_name == "azure_openai": + if not p or not p.api_key or not p.api_base: + console.print("[red]Error: Azure OpenAI requires api_key and api_base.[/red]") + console.print("Set them in ~/.nanobot/config.json under providers.azure_openai section") + console.print("Use the model field to specify the deployment name.") + raise typer.Exit(1) + + return AzureOpenAIProvider( + api_key=p.api_key, + api_base=p.api_base, + default_model=model, + ) + from nanobot.providers.registry import find_by_name spec = find_by_name(provider_name) if not model.startswith("bedrock/") and not (p and p.api_key) and not (spec and spec.is_oauth): diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 1f2f946..44c7446 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -266,6 +266,7 @@ class ProvidersConfig(Base): """Configuration for LLM providers.""" custom: ProviderConfig = Field(default_factory=ProviderConfig) # Any OpenAI-compatible endpoint + azure_openai: ProviderConfig = Field(default_factory=ProviderConfig) # Azure OpenAI (model = deployment name) anthropic: ProviderConfig = Field(default_factory=ProviderConfig) openai: ProviderConfig = Field(default_factory=ProviderConfig) openrouter: ProviderConfig = Field(default_factory=ProviderConfig) diff --git a/nanobot/providers/__init__.py b/nanobot/providers/__init__.py index b2bb2b9..5bd06f9 100644 --- a/nanobot/providers/__init__.py +++ b/nanobot/providers/__init__.py @@ -3,5 +3,6 @@ from nanobot.providers.base import LLMProvider, LLMResponse from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider +from nanobot.providers.azure_openai_provider import AzureOpenAIProvider -__all__ = ["LLMProvider", "LLMResponse", "LiteLLMProvider", "OpenAICodexProvider"] +__all__ = ["LLMProvider", "LLMResponse", "LiteLLMProvider", "OpenAICodexProvider", "AzureOpenAIProvider"] diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py new file mode 100644 index 0000000..25580ac --- /dev/null +++ b/nanobot/providers/azure_openai_provider.py @@ -0,0 +1,192 @@ +"""Azure OpenAI provider implementation with API version 2024-10-21.""" + +from __future__ import annotations + +import uuid +from typing import Any +from urllib.parse import urljoin + +import httpx +import json_repair + +from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest + + +class AzureOpenAIProvider(LLMProvider): + """ + Azure OpenAI provider with API version 2024-10-21 compliance. + + Features: + - Hardcoded API version 2024-10-21 + - Uses model field as Azure deployment name in URL path + - Uses api-key header instead of Authorization Bearer + - Uses max_completion_tokens instead of max_tokens + - Direct HTTP calls, bypasses LiteLLM + """ + + def __init__( + self, + api_key: str = "", + api_base: str = "", + default_model: str = "gpt-5.2-chat", + ): + super().__init__(api_key, api_base) + self.default_model = default_model + self.api_version = "2024-10-21" + + # Validate required parameters + if not api_key: + raise ValueError("Azure OpenAI api_key is required") + if not api_base: + raise ValueError("Azure OpenAI api_base is required") + + # Ensure api_base ends with / + if not api_base.endswith('/'): + api_base += '/' + self.api_base = api_base + + def _build_chat_url(self, deployment_name: str) -> str: + """Build the Azure OpenAI chat completions URL.""" + # Azure OpenAI URL format: + # https://{resource}.openai.azure.com/openai/deployments/{deployment}/chat/completions?api-version={version} + base_url = self.api_base + if not base_url.endswith('/'): + base_url += '/' + + url = urljoin( + base_url, + f"openai/deployments/{deployment_name}/chat/completions" + ) + return f"{url}?api-version={self.api_version}" + + def _build_headers(self) -> dict[str, str]: + """Build headers for Azure OpenAI API with api-key header.""" + return { + "Content-Type": "application/json", + "api-key": self.api_key, # Azure OpenAI uses api-key header, not Authorization + "x-session-affinity": uuid.uuid4().hex, # For cache locality + } + + def _prepare_request_payload( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + reasoning_effort: str | None = None, + ) -> dict[str, Any]: + """Prepare the request payload with Azure OpenAI 2024-10-21 compliance.""" + payload: dict[str, Any] = { + "messages": self._sanitize_empty_content(messages), + "max_completion_tokens": max(1, max_tokens), # Azure API 2024-10-21 uses max_completion_tokens + "temperature": temperature, + } + + if reasoning_effort: + payload["reasoning_effort"] = reasoning_effort + + if tools: + payload["tools"] = tools + payload["tool_choice"] = "auto" + + return payload + + async def chat( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + reasoning_effort: str | None = None, + ) -> LLMResponse: + """ + Send a chat completion request to Azure OpenAI. + + Args: + messages: List of message dicts with 'role' and 'content'. + tools: Optional list of tool definitions in OpenAI format. + model: Model identifier (used as deployment name). + max_tokens: Maximum tokens in response (mapped to max_completion_tokens). + temperature: Sampling temperature. + reasoning_effort: Optional reasoning effort parameter. + + Returns: + LLMResponse with content and/or tool calls. + """ + deployment_name = model or self.default_model + url = self._build_chat_url(deployment_name) + headers = self._build_headers() + payload = self._prepare_request_payload( + messages, tools, model, max_tokens, temperature, reasoning_effort + ) + + try: + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, json=payload) + if response.status_code != 200: + return LLMResponse( + content=f"Azure OpenAI API Error {response.status_code}: {response.text}", + finish_reason="error", + ) + + response_data = await response.json() + return self._parse_response(response_data) + + except Exception as e: + return LLMResponse( + content=f"Error calling Azure OpenAI: {str(e)}", + finish_reason="error", + ) + + def _parse_response(self, response: dict[str, Any]) -> LLMResponse: + """Parse Azure OpenAI response into our standard format.""" + try: + choice = response["choices"][0] + message = choice["message"] + + tool_calls = [] + if message.get("tool_calls"): + for tc in message["tool_calls"]: + # Parse arguments from JSON string if needed + args = tc["function"]["arguments"] + if isinstance(args, str): + args = json_repair.loads(args) + + tool_calls.append( + ToolCallRequest( + id=tc["id"], + name=tc["function"]["name"], + arguments=args, + ) + ) + + usage = {} + if response.get("usage"): + usage_data = response["usage"] + usage = { + "prompt_tokens": usage_data.get("prompt_tokens", 0), + "completion_tokens": usage_data.get("completion_tokens", 0), + "total_tokens": usage_data.get("total_tokens", 0), + } + + reasoning_content = message.get("reasoning_content") or None + + return LLMResponse( + content=message.get("content"), + tool_calls=tool_calls, + finish_reason=choice.get("finish_reason", "stop"), + usage=usage, + reasoning_content=reasoning_content, + ) + + except (KeyError, IndexError) as e: + return LLMResponse( + content=f"Error parsing Azure OpenAI response: {str(e)}", + finish_reason="error", + ) + + def get_default_model(self) -> str: + """Get the default model (also used as default deployment name).""" + return self.default_model \ No newline at end of file diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index df915b7..93a1138 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -81,6 +81,16 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( is_direct=True, ), + # === Azure OpenAI (direct API calls with API version 2024-10-21) ===== + ProviderSpec( + name="azure_openai", + keywords=("azure", "azure-openai"), + env_key="", + display_name="Azure OpenAI", + litellm_prefix="", + is_direct=True, + ), + # === Gateways (detected by api_key / api_base, not model name) ========= # Gateways can route any model, so they win in fallback. diff --git a/tests/test_azure_openai_provider.py b/tests/test_azure_openai_provider.py new file mode 100644 index 0000000..54338b9 --- /dev/null +++ b/tests/test_azure_openai_provider.py @@ -0,0 +1,356 @@ +"""Test Azure OpenAI provider implementation (updated for model-based deployment names).""" + +import asyncio +import pytest +from unittest.mock import AsyncMock, patch + +from nanobot.providers.azure_openai_provider import AzureOpenAIProvider +from nanobot.providers.base import LLMResponse + + +def test_azure_openai_provider_init(): + """Test AzureOpenAIProvider initialization without deployment_name.""" + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", + default_model="gpt-4o-deployment", + ) + + assert provider.api_key == "test-key" + assert provider.api_base == "https://test-resource.openai.azure.com/" + assert provider.default_model == "gpt-4o-deployment" + assert provider.api_version == "2024-10-21" + + +def test_azure_openai_provider_init_validation(): + """Test AzureOpenAIProvider initialization validation.""" + # Missing api_key + with pytest.raises(ValueError, match="Azure OpenAI api_key is required"): + AzureOpenAIProvider(api_key="", api_base="https://test.com") + + # Missing api_base + with pytest.raises(ValueError, match="Azure OpenAI api_base is required"): + AzureOpenAIProvider(api_key="test", api_base="") + + +def test_build_chat_url(): + """Test Azure OpenAI URL building with different deployment names.""" + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", + default_model="gpt-4o", + ) + + # Test various deployment names + test_cases = [ + ("gpt-4o-deployment", "https://test-resource.openai.azure.com/openai/deployments/gpt-4o-deployment/chat/completions?api-version=2024-10-21"), + ("gpt-35-turbo", "https://test-resource.openai.azure.com/openai/deployments/gpt-35-turbo/chat/completions?api-version=2024-10-21"), + ("custom-model", "https://test-resource.openai.azure.com/openai/deployments/custom-model/chat/completions?api-version=2024-10-21"), + ] + + for deployment_name, expected_url in test_cases: + url = provider._build_chat_url(deployment_name) + assert url == expected_url + + +def test_build_chat_url_api_base_without_slash(): + """Test URL building when api_base doesn't end with slash.""" + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", # No trailing slash + default_model="gpt-4o", + ) + + url = provider._build_chat_url("test-deployment") + expected = "https://test-resource.openai.azure.com/openai/deployments/test-deployment/chat/completions?api-version=2024-10-21" + assert url == expected + + +def test_build_headers(): + """Test Azure OpenAI header building with api-key authentication.""" + provider = AzureOpenAIProvider( + api_key="test-api-key-123", + api_base="https://test-resource.openai.azure.com", + default_model="gpt-4o", + ) + + headers = provider._build_headers() + assert headers["Content-Type"] == "application/json" + assert headers["api-key"] == "test-api-key-123" # Azure OpenAI specific header + assert "x-session-affinity" in headers + + +def test_prepare_request_payload(): + """Test request payload preparation with Azure OpenAI 2024-10-21 compliance.""" + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", + default_model="gpt-4o", + ) + + messages = [{"role": "user", "content": "Hello"}] + payload = provider._prepare_request_payload(messages, max_tokens=1500, temperature=0.8) + + assert payload["messages"] == messages + assert payload["max_completion_tokens"] == 1500 # Azure API 2024-10-21 uses max_completion_tokens + assert payload["temperature"] == 0.8 + assert "tools" not in payload + + # Test with tools + tools = [{"type": "function", "function": {"name": "get_weather", "parameters": {}}}] + payload_with_tools = provider._prepare_request_payload(messages, tools=tools) + assert payload_with_tools["tools"] == tools + assert payload_with_tools["tool_choice"] == "auto" + + # Test with reasoning_effort + payload_with_reasoning = provider._prepare_request_payload(messages, reasoning_effort="medium") + assert payload_with_reasoning["reasoning_effort"] == "medium" + + +@pytest.mark.asyncio +async def test_chat_success(): + """Test successful chat request using model as deployment name.""" + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", + default_model="gpt-4o-deployment", + ) + + # Mock response data + mock_response_data = { + "choices": [{ + "message": { + "content": "Hello! How can I help you today?", + "role": "assistant" + }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 12, + "completion_tokens": 18, + "total_tokens": 30 + } + } + + with patch("httpx.AsyncClient") as mock_client: + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json = AsyncMock(return_value=mock_response_data) + + mock_context = AsyncMock() + mock_context.post = AsyncMock(return_value=mock_response) + mock_client.return_value.__aenter__.return_value = mock_context + + # Test with specific model (deployment name) + messages = [{"role": "user", "content": "Hello"}] + result = await provider.chat(messages, model="custom-deployment") + + assert isinstance(result, LLMResponse) + assert result.content == "Hello! How can I help you today?" + assert result.finish_reason == "stop" + assert result.usage["prompt_tokens"] == 12 + assert result.usage["completion_tokens"] == 18 + assert result.usage["total_tokens"] == 30 + + # Verify URL was built with the provided model as deployment name + call_args = mock_context.post.call_args + expected_url = "https://test-resource.openai.azure.com/openai/deployments/custom-deployment/chat/completions?api-version=2024-10-21" + assert call_args[0][0] == expected_url + + +@pytest.mark.asyncio +async def test_chat_uses_default_model_when_no_model_provided(): + """Test that chat uses default_model when no model is specified.""" + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", + default_model="default-deployment", + ) + + mock_response_data = { + "choices": [{ + "message": {"content": "Response", "role": "assistant"}, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 5, "completion_tokens": 5, "total_tokens": 10} + } + + with patch("httpx.AsyncClient") as mock_client: + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json = AsyncMock(return_value=mock_response_data) + + mock_context = AsyncMock() + mock_context.post = AsyncMock(return_value=mock_response) + mock_client.return_value.__aenter__.return_value = mock_context + + messages = [{"role": "user", "content": "Test"}] + await provider.chat(messages) # No model specified + + # Verify URL was built with default model as deployment name + call_args = mock_context.post.call_args + expected_url = "https://test-resource.openai.azure.com/openai/deployments/default-deployment/chat/completions?api-version=2024-10-21" + assert call_args[0][0] == expected_url + + +@pytest.mark.asyncio +async def test_chat_with_tool_calls(): + """Test chat request with tool calls in response.""" + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", + default_model="gpt-4o", + ) + + # Mock response with tool calls + mock_response_data = { + "choices": [{ + "message": { + "content": None, + "role": "assistant", + "tool_calls": [{ + "id": "call_12345", + "function": { + "name": "get_weather", + "arguments": '{"location": "San Francisco"}' + } + }] + }, + "finish_reason": "tool_calls" + }], + "usage": { + "prompt_tokens": 20, + "completion_tokens": 15, + "total_tokens": 35 + } + } + + with patch("httpx.AsyncClient") as mock_client: + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json = AsyncMock(return_value=mock_response_data) + + mock_context = AsyncMock() + mock_context.post = AsyncMock(return_value=mock_response) + mock_client.return_value.__aenter__.return_value = mock_context + + messages = [{"role": "user", "content": "What's the weather?"}] + tools = [{"type": "function", "function": {"name": "get_weather", "parameters": {}}}] + result = await provider.chat(messages, tools=tools, model="weather-model") + + assert isinstance(result, LLMResponse) + assert result.content is None + assert result.finish_reason == "tool_calls" + assert len(result.tool_calls) == 1 + assert result.tool_calls[0].name == "get_weather" + assert result.tool_calls[0].arguments == {"location": "San Francisco"} + + +@pytest.mark.asyncio +async def test_chat_api_error(): + """Test chat request API error handling.""" + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", + default_model="gpt-4o", + ) + + with patch("httpx.AsyncClient") as mock_client: + mock_response = AsyncMock() + mock_response.status_code = 401 + mock_response.text = "Invalid authentication credentials" + + mock_context = AsyncMock() + mock_context.post = AsyncMock(return_value=mock_response) + mock_client.return_value.__aenter__.return_value = mock_context + + messages = [{"role": "user", "content": "Hello"}] + result = await provider.chat(messages) + + assert isinstance(result, LLMResponse) + assert "Azure OpenAI API Error 401" in result.content + assert "Invalid authentication credentials" in result.content + assert result.finish_reason == "error" + + +@pytest.mark.asyncio +async def test_chat_connection_error(): + """Test chat request connection error handling.""" + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", + default_model="gpt-4o", + ) + + with patch("httpx.AsyncClient") as mock_client: + mock_context = AsyncMock() + mock_context.post = AsyncMock(side_effect=Exception("Connection failed")) + mock_client.return_value.__aenter__.return_value = mock_context + + messages = [{"role": "user", "content": "Hello"}] + result = await provider.chat(messages) + + assert isinstance(result, LLMResponse) + assert "Error calling Azure OpenAI: Connection failed" in result.content + assert result.finish_reason == "error" + + +def test_parse_response_malformed(): + """Test response parsing with malformed data.""" + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", + default_model="gpt-4o", + ) + + # Test with missing choices + malformed_response = {"usage": {"prompt_tokens": 10}} + result = provider._parse_response(malformed_response) + + assert isinstance(result, LLMResponse) + assert "Error parsing Azure OpenAI response" in result.content + assert result.finish_reason == "error" + + +def test_get_default_model(): + """Test get_default_model method.""" + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", + default_model="my-custom-deployment", + ) + + assert provider.get_default_model() == "my-custom-deployment" + + +if __name__ == "__main__": + # Run basic tests + print("Running basic Azure OpenAI provider tests...") + + # Test initialization + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", + default_model="gpt-4o-deployment", + ) + print("✅ Provider initialization successful") + + # Test URL building + url = provider._build_chat_url("my-deployment") + expected = "https://test-resource.openai.azure.com/openai/deployments/my-deployment/chat/completions?api-version=2024-10-21" + assert url == expected + print("✅ URL building works correctly") + + # Test headers + headers = provider._build_headers() + assert headers["api-key"] == "test-key" + assert headers["Content-Type"] == "application/json" + print("✅ Header building works correctly") + + # Test payload preparation + messages = [{"role": "user", "content": "Test"}] + payload = provider._prepare_request_payload(messages, max_tokens=1000) + assert payload["max_completion_tokens"] == 1000 # Azure 2024-10-21 format + print("✅ Payload preparation works correctly") + + print("✅ All basic tests passed! Updated test file is working correctly.") \ No newline at end of file From 52e725053c149745c62a3896339b6c81a193df2c Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Fri, 6 Mar 2026 09:20:47 +0000 Subject: [PATCH 2/9] Always use temperature 1 --- nanobot/providers/azure_openai_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py index 25580ac..c849fd9 100644 --- a/nanobot/providers/azure_openai_provider.py +++ b/nanobot/providers/azure_openai_provider.py @@ -80,7 +80,7 @@ class AzureOpenAIProvider(LLMProvider): payload: dict[str, Any] = { "messages": self._sanitize_empty_content(messages), "max_completion_tokens": max(1, max_tokens), # Azure API 2024-10-21 uses max_completion_tokens - "temperature": temperature, + "temperature": 1, } if reasoning_effort: From 7684f5b9029cc38e18069491ee692e09687de8c2 Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Fri, 6 Mar 2026 09:49:26 +0000 Subject: [PATCH 3/9] Fix the temperature issue, remove temperature --- nanobot/providers/azure_openai_provider.py | 7 ++----- tests/test_azure_openai_provider.py | 12 ++++++------ 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py index c849fd9..52efde6 100644 --- a/nanobot/providers/azure_openai_provider.py +++ b/nanobot/providers/azure_openai_provider.py @@ -71,16 +71,13 @@ class AzureOpenAIProvider(LLMProvider): self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, - model: str | None = None, max_tokens: int = 4096, - temperature: float = 0.7, reasoning_effort: str | None = None, ) -> dict[str, Any]: """Prepare the request payload with Azure OpenAI 2024-10-21 compliance.""" payload: dict[str, Any] = { "messages": self._sanitize_empty_content(messages), "max_completion_tokens": max(1, max_tokens), # Azure API 2024-10-21 uses max_completion_tokens - "temperature": 1, } if reasoning_effort: @@ -119,7 +116,7 @@ class AzureOpenAIProvider(LLMProvider): url = self._build_chat_url(deployment_name) headers = self._build_headers() payload = self._prepare_request_payload( - messages, tools, model, max_tokens, temperature, reasoning_effort + messages, tools, max_tokens, reasoning_effort ) try: @@ -131,7 +128,7 @@ class AzureOpenAIProvider(LLMProvider): finish_reason="error", ) - response_data = await response.json() + response_data = response.json() return self._parse_response(response_data) except Exception as e: diff --git a/tests/test_azure_openai_provider.py b/tests/test_azure_openai_provider.py index 54338b9..df2cdc3 100644 --- a/tests/test_azure_openai_provider.py +++ b/tests/test_azure_openai_provider.py @@ -2,7 +2,7 @@ import asyncio import pytest -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch from nanobot.providers.azure_openai_provider import AzureOpenAIProvider from nanobot.providers.base import LLMResponse @@ -89,11 +89,11 @@ def test_prepare_request_payload(): ) messages = [{"role": "user", "content": "Hello"}] - payload = provider._prepare_request_payload(messages, max_tokens=1500, temperature=0.8) + payload = provider._prepare_request_payload(messages, max_tokens=1500) assert payload["messages"] == messages assert payload["max_completion_tokens"] == 1500 # Azure API 2024-10-21 uses max_completion_tokens - assert payload["temperature"] == 0.8 + assert "temperature" not in payload # Temperature not included in payload assert "tools" not in payload # Test with tools @@ -135,7 +135,7 @@ async def test_chat_success(): with patch("httpx.AsyncClient") as mock_client: mock_response = AsyncMock() mock_response.status_code = 200 - mock_response.json = AsyncMock(return_value=mock_response_data) + mock_response.json = Mock(return_value=mock_response_data) mock_context = AsyncMock() mock_context.post = AsyncMock(return_value=mock_response) @@ -178,7 +178,7 @@ async def test_chat_uses_default_model_when_no_model_provided(): with patch("httpx.AsyncClient") as mock_client: mock_response = AsyncMock() mock_response.status_code = 200 - mock_response.json = AsyncMock(return_value=mock_response_data) + mock_response.json = Mock(return_value=mock_response_data) mock_context = AsyncMock() mock_context.post = AsyncMock(return_value=mock_response) @@ -228,7 +228,7 @@ async def test_chat_with_tool_calls(): with patch("httpx.AsyncClient") as mock_client: mock_response = AsyncMock() mock_response.status_code = 200 - mock_response.json = AsyncMock(return_value=mock_response_data) + mock_response.json = Mock(return_value=mock_response_data) mock_context = AsyncMock() mock_context.post = AsyncMock(return_value=mock_response) From 0b0f47f09fd37f1c354ba5a656b8f756d72a3382 Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Fri, 6 Mar 2026 10:37:16 +0000 Subject: [PATCH 4/9] Update readme with azure openai support --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index fc0a1fb..61f38f4 100644 --- a/README.md +++ b/README.md @@ -670,6 +670,7 @@ Config file: `~/.nanobot/config.json` | `custom` | Any OpenAI-compatible endpoint (direct, no LiteLLM) | — | | `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | | `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | +| `azure_openai` | LLM (Azure OpenAI) | [portal.azure.com](https://portal.azure.com) | | `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | | `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | | `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | From a8ce0a3084be719d1b21726e6560bc64a26deff1 Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Fri, 6 Mar 2026 16:05:43 +0000 Subject: [PATCH 5/9] Adding some more insights for failure in Azure OpenAI calls --- nanobot/providers/azure_openai_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py index 52efde6..fc8e950 100644 --- a/nanobot/providers/azure_openai_provider.py +++ b/nanobot/providers/azure_openai_provider.py @@ -133,7 +133,7 @@ class AzureOpenAIProvider(LLMProvider): except Exception as e: return LLMResponse( - content=f"Error calling Azure OpenAI: {str(e)}", + content=f"Error calling Azure OpenAI: {repr(e)}", finish_reason="error", ) From 43022b17184070ce6b1a4fe487b27517238050d7 Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Fri, 6 Mar 2026 17:20:52 +0000 Subject: [PATCH 6/9] Fix unit test after updating error message --- tests/test_azure_openai_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_azure_openai_provider.py b/tests/test_azure_openai_provider.py index df2cdc3..680ddf4 100644 --- a/tests/test_azure_openai_provider.py +++ b/tests/test_azure_openai_provider.py @@ -291,7 +291,7 @@ async def test_chat_connection_error(): result = await provider.chat(messages) assert isinstance(result, LLMResponse) - assert "Error calling Azure OpenAI: Connection failed" in result.content + assert "Error calling Azure OpenAI: Exception('Connection failed')" in result.content assert result.finish_reason == "error" From 7e4594e08dc74ab438d3d903a1fac6441a498615 Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Fri, 6 Mar 2026 18:12:46 +0000 Subject: [PATCH 7/9] Increase timeout for chat completion calls --- nanobot/providers/azure_openai_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py index fc8e950..6da37e7 100644 --- a/nanobot/providers/azure_openai_provider.py +++ b/nanobot/providers/azure_openai_provider.py @@ -120,7 +120,7 @@ class AzureOpenAIProvider(LLMProvider): ) try: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(timeout=60.0) as client: response = await client.post(url, headers=headers, json=payload) if response.status_code != 200: return LLMResponse( From 73be53d4bd7e5ff7363644248ab47296959bd3c9 Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Fri, 6 Mar 2026 18:16:15 +0000 Subject: [PATCH 8/9] Add SSL verification --- nanobot/providers/azure_openai_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py index 6da37e7..3f325aa 100644 --- a/nanobot/providers/azure_openai_provider.py +++ b/nanobot/providers/azure_openai_provider.py @@ -120,7 +120,7 @@ class AzureOpenAIProvider(LLMProvider): ) try: - async with httpx.AsyncClient(timeout=60.0) as client: + async with httpx.AsyncClient(timeout=60.0, verify=True) as client: response = await client.post(url, headers=headers, json=payload) if response.status_code != 200: return LLMResponse( From 576ad12ef16fbf7813fb88d46f43c48a23d98ed8 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 7 Mar 2026 03:57:57 +0000 Subject: [PATCH 9/9] fix(azure): sanitize messages and handle temperature --- nanobot/providers/azure_openai_provider.py | 25 +++++++++- nanobot/providers/base.py | 14 ++++++ nanobot/providers/litellm_provider.py | 10 +--- tests/test_azure_openai_provider.py | 57 +++++++++++++++++++--- 4 files changed, 89 insertions(+), 17 deletions(-) diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py index 3f325aa..bd79b00 100644 --- a/nanobot/providers/azure_openai_provider.py +++ b/nanobot/providers/azure_openai_provider.py @@ -11,6 +11,8 @@ import json_repair from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest +_AZURE_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name"}) + class AzureOpenAIProvider(LLMProvider): """ @@ -67,19 +69,38 @@ class AzureOpenAIProvider(LLMProvider): "x-session-affinity": uuid.uuid4().hex, # For cache locality } + @staticmethod + def _supports_temperature( + deployment_name: str, + reasoning_effort: str | None = None, + ) -> bool: + """Return True when temperature is likely supported for this deployment.""" + if reasoning_effort: + return False + name = deployment_name.lower() + return not any(token in name for token in ("gpt-5", "o1", "o3", "o4")) + def _prepare_request_payload( self, + deployment_name: str, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, max_tokens: int = 4096, + temperature: float = 0.7, reasoning_effort: str | None = None, ) -> dict[str, Any]: """Prepare the request payload with Azure OpenAI 2024-10-21 compliance.""" payload: dict[str, Any] = { - "messages": self._sanitize_empty_content(messages), + "messages": self._sanitize_request_messages( + self._sanitize_empty_content(messages), + _AZURE_MSG_KEYS, + ), "max_completion_tokens": max(1, max_tokens), # Azure API 2024-10-21 uses max_completion_tokens } + if self._supports_temperature(deployment_name, reasoning_effort): + payload["temperature"] = temperature + if reasoning_effort: payload["reasoning_effort"] = reasoning_effort @@ -116,7 +137,7 @@ class AzureOpenAIProvider(LLMProvider): url = self._build_chat_url(deployment_name) headers = self._build_headers() payload = self._prepare_request_payload( - messages, tools, max_tokens, reasoning_effort + deployment_name, messages, tools, max_tokens, temperature, reasoning_effort ) try: diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index 55bd805..0f73544 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -87,6 +87,20 @@ class LLMProvider(ABC): result.append(msg) return result + @staticmethod + def _sanitize_request_messages( + messages: list[dict[str, Any]], + allowed_keys: frozenset[str], + ) -> list[dict[str, Any]]: + """Keep only provider-safe message keys and normalize assistant content.""" + sanitized = [] + for msg in messages: + clean = {k: v for k, v in msg.items() if k in allowed_keys} + if clean.get("role") == "assistant" and "content" not in clean: + clean["content"] = None + sanitized.append(clean) + return sanitized + @abstractmethod async def chat( self, diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 2fd6c18..cb67635 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -180,7 +180,7 @@ class LiteLLMProvider(LLMProvider): def _sanitize_messages(messages: list[dict[str, Any]], extra_keys: frozenset[str] = frozenset()) -> list[dict[str, Any]]: """Strip non-standard keys and ensure assistant messages have a content key.""" allowed = _ALLOWED_MSG_KEYS | extra_keys - sanitized = [] + sanitized = LLMProvider._sanitize_request_messages(messages, allowed) id_map: dict[str, str] = {} def map_id(value: Any) -> Any: @@ -188,12 +188,7 @@ class LiteLLMProvider(LLMProvider): return value return id_map.setdefault(value, LiteLLMProvider._normalize_tool_call_id(value)) - for msg in messages: - clean = {k: v for k, v in msg.items() if k in allowed} - # Strict providers require "content" even when assistant only has tool_calls - if clean.get("role") == "assistant" and "content" not in clean: - clean["content"] = None - + for clean in sanitized: # Keep assistant tool_calls[].id and tool tool_call_id in sync after # shortening, otherwise strict providers reject the broken linkage. if isinstance(clean.get("tool_calls"), list): @@ -209,7 +204,6 @@ class LiteLLMProvider(LLMProvider): if "tool_call_id" in clean and clean["tool_call_id"]: clean["tool_call_id"] = map_id(clean["tool_call_id"]) - sanitized.append(clean) return sanitized async def chat( diff --git a/tests/test_azure_openai_provider.py b/tests/test_azure_openai_provider.py index 680ddf4..77f36d4 100644 --- a/tests/test_azure_openai_provider.py +++ b/tests/test_azure_openai_provider.py @@ -1,9 +1,9 @@ """Test Azure OpenAI provider implementation (updated for model-based deployment names).""" -import asyncio -import pytest from unittest.mock import AsyncMock, Mock, patch +import pytest + from nanobot.providers.azure_openai_provider import AzureOpenAIProvider from nanobot.providers.base import LLMResponse @@ -89,22 +89,65 @@ def test_prepare_request_payload(): ) messages = [{"role": "user", "content": "Hello"}] - payload = provider._prepare_request_payload(messages, max_tokens=1500) + payload = provider._prepare_request_payload("gpt-4o", messages, max_tokens=1500, temperature=0.8) assert payload["messages"] == messages assert payload["max_completion_tokens"] == 1500 # Azure API 2024-10-21 uses max_completion_tokens - assert "temperature" not in payload # Temperature not included in payload + assert payload["temperature"] == 0.8 assert "tools" not in payload # Test with tools tools = [{"type": "function", "function": {"name": "get_weather", "parameters": {}}}] - payload_with_tools = provider._prepare_request_payload(messages, tools=tools) + payload_with_tools = provider._prepare_request_payload("gpt-4o", messages, tools=tools) assert payload_with_tools["tools"] == tools assert payload_with_tools["tool_choice"] == "auto" # Test with reasoning_effort - payload_with_reasoning = provider._prepare_request_payload(messages, reasoning_effort="medium") + payload_with_reasoning = provider._prepare_request_payload( + "gpt-5-chat", messages, reasoning_effort="medium" + ) assert payload_with_reasoning["reasoning_effort"] == "medium" + assert "temperature" not in payload_with_reasoning + + +def test_prepare_request_payload_sanitizes_messages(): + """Test Azure payload strips non-standard message keys before sending.""" + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", + default_model="gpt-4o", + ) + + messages = [ + { + "role": "assistant", + "tool_calls": [{"id": "call_123", "type": "function", "function": {"name": "x"}}], + "reasoning_content": "hidden chain-of-thought", + }, + { + "role": "tool", + "tool_call_id": "call_123", + "name": "x", + "content": "ok", + "extra_field": "should be removed", + }, + ] + + payload = provider._prepare_request_payload("gpt-4o", messages) + + assert payload["messages"] == [ + { + "role": "assistant", + "content": None, + "tool_calls": [{"id": "call_123", "type": "function", "function": {"name": "x"}}], + }, + { + "role": "tool", + "tool_call_id": "call_123", + "name": "x", + "content": "ok", + }, + ] @pytest.mark.asyncio @@ -349,7 +392,7 @@ if __name__ == "__main__": # Test payload preparation messages = [{"role": "user", "content": "Test"}] - payload = provider._prepare_request_payload(messages, max_tokens=1000) + payload = provider._prepare_request_payload("gpt-4o-deployment", messages, max_tokens=1000) assert payload["max_completion_tokens"] == 1000 # Azure 2024-10-21 format print("✅ Payload preparation works correctly")