Add support for Azure AAD based Auth

This commit is contained in:
Kunal Karmakar 2026-05-31 15:08:01 +00:00 committed by Xubin Ren
parent 39454534d4
commit ba3fa38e97
5 changed files with 241 additions and 14 deletions

View File

@ -205,6 +205,72 @@ Valid `apiType` values are exactly `auto`, `chat_completions`, and `responses`.
</details>
<details>
<summary><b>Azure OpenAI</b></summary>
The `azure_openai` provider talks to your Azure OpenAI resource via the OpenAI **Responses API** (`/openai/v1/responses`). Model names map to **deployment names**, not OpenAI model IDs. Two authentication modes are supported.
**Mode 1: Static API key** (simplest)
```json
{
"providers": {
"azure_openai": {
"apiKey": "${AZURE_OPENAI_API_KEY}",
"apiBase": "https://my-resource.openai.azure.com"
}
},
"agents": {
"defaults": {
"provider": "azure_openai",
"model": "my-gpt-5-deployment"
}
}
}
```
**Mode 2: Microsoft Entra ID (Azure AD) via `DefaultAzureCredential`**
Omit `apiKey` (or leave it empty / unset). The provider falls back to [`DefaultAzureCredential`](https://learn.microsoft.com/azure/developer/python/sdk/authentication/credential-chains#defaultazurecredential-overview) and acquires a bearer token scoped to `https://cognitiveservices.azure.com/.default` for every request. The Azure SDK's own MSAL-backed cache returns valid tokens without a network round-trip.
```json
{
"providers": {
"azure_openai": {
"apiBase": "https://my-resource.openai.azure.com"
}
},
"agents": {
"defaults": {
"provider": "azure_openai",
"model": "my-gpt-5-deployment"
}
}
}
```
Install the optional dependency:
```bash
pip install 'nanobot-ai[azure]'
```
`DefaultAzureCredential` walks this chain in order and uses the first identity that succeeds:
1. **EnvironmentCredential** — reads `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, and one of `AZURE_CLIENT_SECRET` / `AZURE_CLIENT_CERTIFICATE_PATH` / `AZURE_USERNAME` + `AZURE_PASSWORD`.
2. **WorkloadIdentityCredential** — for AKS workload-identity / federated tokens (`AZURE_FEDERATED_TOKEN_FILE`).
3. **ManagedIdentityCredential** — for Azure VMs, App Service, Functions, Container Apps, etc.
4. **AzureCliCredential** — uses the token from `az login` on your dev machine.
5. **AzurePowerShellCredential** — uses the token from `Connect-AzAccount`.
6. **AzureDeveloperCliCredential** — uses the token from `azd auth login`.
7. **InteractiveBrowserCredential** *(disabled by default)*.
The identity that ends up signing the request **must be assigned the `Cognitive Services OpenAI User` RBAC role** (or higher) on the Azure OpenAI resource. Without that role you will see `401`/`403` errors at the first request.
> `apiBase` remains mandatory in both modes — it's your Azure resource endpoint and cannot be inferred. If neither `apiKey` is set nor `azure-identity` is installed, the provider raises a clear error pointing you at `pip install 'nanobot-ai[azure]'`.
</details>
<details>
<summary><b>Skywork / APIFree</b></summary>

View File

@ -3,6 +3,18 @@
Uses ``AsyncOpenAI`` pointed at ``https://{endpoint}/openai/v1/`` which
routes to the Responses API (``/responses``). Reuses shared conversion
helpers from :mod:`nanobot.providers.openai_responses`.
Authentication
--------------
Two modes are supported, selected automatically:
1. **Static API key** when ``api_key`` is non-empty it is sent as the
``api-key`` / ``Authorization: Bearer`` header (existing behavior).
2. **Microsoft Entra ID (AAD)** when ``api_key`` is empty the provider
falls back to :class:`azure.identity.aio.DefaultAzureCredential` and
acquires a bearer token scoped to
``https://cognitiveservices.azure.com/.default``. ``azure-identity``
is an optional dependency installed via ``pip install nanobot-ai[azure]``.
"""
from __future__ import annotations
@ -21,6 +33,48 @@ from nanobot.providers.openai_responses import (
parse_response_output,
)
_AZURE_OPENAI_SCOPE = "https://cognitiveservices.azure.com/.default"
class _AzureTokenProvider:
"""Async bearer-token callback for AAD authentication.
Thin wrapper around :class:`azure.identity.aio.DefaultAzureCredential`
that exposes itself as an async callable returning a fresh bearer
token. The Azure SDK's own MSAL-backed token cache already returns
valid tokens without network calls, so no extra caching is layered on
top here.
Raises ``RuntimeError`` with a clear install hint if
``azure-identity`` is not installed.
"""
def __init__(self, scope: str = _AZURE_OPENAI_SCOPE) -> None:
try:
from azure.identity.aio import DefaultAzureCredential
except ImportError as exc:
raise RuntimeError(
"Azure OpenAI AAD authentication requires the 'azure-identity' package. "
"Install it with: pip install 'nanobot-ai[azure]'"
) from exc
self._scope = scope
self._credential = DefaultAzureCredential()
async def __call__(self) -> str:
"""Return a bearer token for the configured scope."""
access_token = await self._credential.get_token(self._scope)
return access_token.token
async def aclose(self) -> None:
"""Release credential resources. Safe to call multiple times."""
close = getattr(self._credential, "close", None)
if close is not None:
try:
await close()
except Exception:
pass
class AzureOpenAIProvider(LLMProvider):
"""Azure OpenAI provider backed by the Responses API.
@ -31,6 +85,8 @@ class AzureOpenAIProvider(LLMProvider):
- Calls ``client.responses.create()`` (Responses API)
- Reuses shared message/tool/SSE conversion from
``openai_responses``
- Falls back to :class:`DefaultAzureCredential` (AAD) when ``api_key``
is empty. See module docstring for details.
"""
def __init__(
@ -42,8 +98,6 @@ class AzureOpenAIProvider(LLMProvider):
super().__init__(api_key, api_base)
self.default_model = default_model
if not api_key:
raise ValueError("Azure OpenAI api_key is required")
if not api_base:
raise ValueError("Azure OpenAI api_base is required")
@ -52,10 +106,22 @@ class AzureOpenAIProvider(LLMProvider):
api_base += "/"
self.api_base = api_base
# Select auth mode. A truthy api_key wins; otherwise fall back to
# AAD via DefaultAzureCredential. The OpenAI SDK accepts an async
# callable as ``api_key`` and invokes it per request, using the
# returned string as the bearer token.
self._token_provider: _AzureTokenProvider | None = None
client_api_key: str | Callable[[], Awaitable[str]]
if api_key:
client_api_key = api_key
else:
self._token_provider = _AzureTokenProvider()
client_api_key = self._token_provider
# SDK client targeting the Azure Responses API endpoint
base_url = f"{api_base.rstrip('/')}/openai/v1/"
self._client = AsyncOpenAI(
api_key=api_key,
api_key=client_api_key,
base_url=base_url,
default_headers={"x-session-affinity": uuid.uuid4().hex},
max_retries=0,

View File

@ -44,8 +44,8 @@ def _make_provider_core(
backend = spec.backend if spec else "openai_compat"
if backend == "azure_openai":
if not p or not p.api_key or not p.api_base:
raise ValueError("Azure OpenAI requires api_key and api_base in config.")
if not p or not p.api_base:
raise ValueError("Azure OpenAI requires api_base in config.")
elif backend == "openai_compat" and not model.startswith("bedrock/"):
needs_key = not (p and p.api_key)
exempt = spec and (spec.is_oauth or spec.is_local or spec.is_direct)
@ -60,7 +60,7 @@ def _make_provider_core(
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
provider = AzureOpenAIProvider(
api_key=p.api_key,
api_key=p.api_key or "",
api_base=p.api_base,
default_model=model,
)

View File

@ -68,6 +68,9 @@ dependencies = [
api = [
"aiohttp>=3.9.0,<4.0.0",
]
azure = [
"azure-identity>=1.19.0,<2.0.0",
]
wecom = [
"wecom-aibot-sdk-python>=0.1.5",
]

View File

@ -1,13 +1,18 @@
"""Test Azure OpenAI provider (Responses API via OpenAI SDK)."""
from unittest.mock import AsyncMock, MagicMock
import sys
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
from nanobot.providers.azure_openai_provider import (
AzureOpenAIProvider,
_AzureTokenProvider,
)
from nanobot.providers.base import LLMResponse
# ---------------------------------------------------------------------------
# Init & validation
# ---------------------------------------------------------------------------
@ -25,6 +30,8 @@ def test_init_creates_sdk_client():
assert provider.default_model == "gpt-4o-deployment"
# SDK client base_url ends with /openai/v1/
assert str(provider._client.base_url).rstrip("/").endswith("/openai/v1")
# Static-key path must NOT construct an AAD token provider
assert provider._token_provider is None
def test_init_base_url_no_trailing_slash():
@ -42,11 +49,6 @@ def test_init_base_url_with_trailing_slash():
assert str(provider._client.base_url).rstrip("/").endswith("/openai/v1")
def test_init_validation_missing_key():
with pytest.raises(ValueError, match="Azure OpenAI api_key is required"):
AzureOpenAIProvider(api_key="", api_base="https://test.com")
def test_init_validation_missing_base():
with pytest.raises(ValueError, match="Azure OpenAI api_base is required"):
AzureOpenAIProvider(api_key="test", api_base="")
@ -59,6 +61,96 @@ def test_no_api_version_in_base_url():
assert "api-version" not in base
# ---------------------------------------------------------------------------
# AAD / DefaultAzureCredential fallback
# ---------------------------------------------------------------------------
def _install_fake_azure_identity(monkeypatch, credential_factory):
"""Install a fake ``azure.identity.aio`` module exposing ``DefaultAzureCredential``."""
azure_mod = sys.modules.get("azure") or SimpleNamespace()
identity_mod = SimpleNamespace()
aio_mod = SimpleNamespace(DefaultAzureCredential=credential_factory)
identity_mod.aio = aio_mod
azure_mod.identity = identity_mod # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "azure", azure_mod)
monkeypatch.setitem(sys.modules, "azure.identity", identity_mod)
monkeypatch.setitem(sys.modules, "azure.identity.aio", aio_mod)
def test_init_missing_key_uses_aad_token_provider(monkeypatch):
"""Empty api_key falls back to DefaultAzureCredential via _AzureTokenProvider."""
credential_instance = MagicMock()
credential_factory = MagicMock(return_value=credential_instance)
_install_fake_azure_identity(monkeypatch, credential_factory)
provider = AzureOpenAIProvider(
api_key="", api_base="https://res.openai.azure.com",
)
assert provider._token_provider is not None
assert isinstance(provider._token_provider, _AzureTokenProvider)
# DefaultAzureCredential must have been instantiated exactly once
credential_factory.assert_called_once_with()
# The SDK client must have received the token provider as its api_key
# (the SDK stores it on the auth wrapper, not directly accessible — so
# we assert the callable was wired in via the provider attribute).
assert provider._token_provider._credential is credential_instance
def test_init_explicit_key_does_not_construct_credential(monkeypatch):
"""Explicit api_key wins; DefaultAzureCredential must never be touched."""
credential_factory = MagicMock(side_effect=AssertionError(
"DefaultAzureCredential must not be constructed when api_key is set"
))
_install_fake_azure_identity(monkeypatch, credential_factory)
provider = AzureOpenAIProvider(
api_key="real-key", api_base="https://res.openai.azure.com",
)
assert provider._token_provider is None
credential_factory.assert_not_called()
def test_init_missing_key_without_azure_identity_raises(monkeypatch):
"""Clear RuntimeError with pip-install hint when azure-identity is missing."""
# Force the import inside _AzureTokenProvider to fail.
real_import = __builtins__["__import__"] if isinstance(__builtins__, dict) else __builtins__.__import__
def fake_import(name, *args, **kwargs):
if name == "azure.identity.aio":
raise ImportError("No module named 'azure.identity.aio'")
return real_import(name, *args, **kwargs)
with patch("builtins.__import__", side_effect=fake_import):
with pytest.raises(RuntimeError, match=r"pip install 'nanobot-ai\[azure\]'"):
AzureOpenAIProvider(api_key="", api_base="https://res.openai.azure.com")
@pytest.mark.asyncio
async def test_token_provider_returns_credential_token(monkeypatch):
"""Token callback delegates to DefaultAzureCredential.get_token with the AOAI scope."""
access_token = SimpleNamespace(token="token-A", expires_on=time.time() + 3600)
credential_instance = MagicMock()
credential_instance.get_token = AsyncMock(return_value=access_token)
credential_factory = MagicMock(return_value=credential_instance)
_install_fake_azure_identity(monkeypatch, credential_factory)
tp = _AzureTokenProvider()
assert await tp() == "token-A"
credential_instance.get_token.assert_awaited_with(
"https://cognitiveservices.azure.com/.default"
)
# No client-side caching layer — every call delegates to the Azure SDK,
# which has its own MSAL-backed token cache.
assert await tp() == "token-A"
assert credential_instance.get_token.await_count == 2
# ---------------------------------------------------------------------------
# _supports_temperature
# ---------------------------------------------------------------------------